3 - Create a to-do

Adding an Add Todo button

To create a to-do item we are going to need a button that triggers a function which adds a to-do item to our to-do list. Let's put all of the logic and UI to add a to-do into a separate component in src/components/AddTodo/index.js

// components/AddTodo/index.js

import React from "react";

function AddTodo() {
  return <button className="add-button">Add Todo</button>;
}

export default AddTodo;

In our App, we are going to display this AddTodo button to logged in users only:

// App.jsx

import AddTodo from "../src/components/AddTodo";

function App() {
// ...
      {session.info.isLoggedIn ? (
        <CombinedDataProvider
          datasetUrl={session.info.webId}
          thingUrl={session.info.webId}
        >
          <div className="message logged-in">
            <span>You are logged in as: </span>
            <Text
              properties={[
                "http://xmlns.com/foaf/0.1/name",
                "http://www.w3.org/2006/vcard/ns#fn",
              ]} />
            <LogoutButton />
          </div>
          <section>
            <AddTodo />
          </section>
        </CombinedDataProvider>
      ) : 
// ...
}

For now this button doesn't do anything. Let's change that.

Initializing the to-dos dataset

In formal terms, each of our to-do items will be structured as things that are grouped inside a dataset, so first we need to check if the dataset already exists, and if not, we must create it. Let's write a function that does this, assuming our structured data will be stored in a folder called "todos" in the root of our pod.

💡
The proper way to do this would be to check the profile (i.e. the data at the user's WebID), look for a URL for a known predicate (e.g. myVocab:todolistContainer), and then follow that link to get to this folder. Only if no such link exists, would the app initialize its own folder - and after initialization, it would link back to that from the user's WebID. For that we would need to create a new vocab, and for simplicity's sake that is not included in this tutorial.

Let's put this function in src/utils/index.js because we might use it again in the future somewhere aside from our AddTodo component.

// utils/index.js

import {
  createSolidDataset,
  getSolidDataset,
  saveSolidDatasetAt,
} from "@inrupt/solid-client";

export async function getOrCreateTodoList(containerUri, fetch) {
  const indexUrl = `${containerUri}index.ttl`;
  try {
    const todoList = await getSolidDataset(indexUrl, { fetch });
    return todoList;
  } catch (error) {
    if (error.statusCode === 404) {
      const todoList = await saveSolidDatasetAt(
        indexUrl,
        createSolidDataset(),
        {
          fetch,
        }
      );
      return todoList;
    }
  }
}

We are using three functions from solid-client here to read and write data in our Pods:

If the to-do list index file is found, our getOrCreateTodoList function will return it. If not (if there is a 404 error), it will create the file at the location given.

Now we can use this function in our AddTodo component. We need to pass it a container URI, which we make by concatenating the Pod URI with the folder name we have chosen to store our to-do list. So first we need to:

Once we have the container URL, we can now check if the to-do list dataset exists, or create it, and use it anywhere in the component:

// components/AddTodo/index.js

import { getSolidDataset, getThing, getUrlAll } from "@inrupt/solid-client";
import { useSession } from "@inrupt/solid-ui-react";
import React, { useEffect, useState } from "react";
import { getOrCreateTodoList } from "../../utils";

function AddTodo() {
  const { session } = useSession();
  const [todoList, setTodoList] = useState();

  useEffect(() => {
    if (!session) return;
    (async () => {
      const profileDataset = await getSolidDataset(session.info.webId, {
        fetch: session.fetch,
      });
      const profileThing = getThing(profileDataset, session.info.webId);
      const podsUrls = getUrlAll(
        profileThing,
        "http://www.w3.org/ns/pim/space#storage"
      );
      const pod = podsUrls[0];
      const containerUri = `${pod}todos/`;
      const list = await getOrCreateTodoList(containerUri, session.fetch);
      setTodoList(list);
    })();
  }, [session]);

  return <button className="add-button">Add Todo</button>;
}

export default AddTodo;

To check if it worked, go to PodBrowser, log in by selecting your Pod Provider from the dropdown (in our case `https://broker.pod.inrupt.com/`), enter your username and password, and check that the "todos" folder was created in your Pod.

If you go into the "todos" container, there should be a index.ttl file in it.

If you click on the index.ttl a drawer will open up to the right with a "Download" link. Click on it to download the file, which you can open with any text editor, such as Notepad. The contents of the file should look like this:

@prefix as:    <https://www.w3.org/ns/activitystreams#> .
@prefix rdf:   <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix xsd:   <http://www.w3.org/2001/XMLSchema#> .
@prefix ldp:   <http://www.w3.org/ns/ldp#> .
@prefix skos:  <http://www.w3.org/2004/02/skos/core#> .
@prefix rdfs:  <http://www.w3.org/2000/01/rdf-schema#> .
@prefix acl:   <http://www.w3.org/ns/auth/acl#> .
@prefix vcard: <http://www.w3.org/2006/vcard/ns#> .
@prefix foaf:  <http://xmlns.com/foaf/0.1/> .
@prefix dc:    <http://purl.org/dc/terms/> .
@prefix acp:   <http://www.w3.org/ns/solid/acp#> .

<https://pod.inrupt.com/virginiabalseiro/todos/index.ttl>
        rdf:type  ldp:RDFSource .

This is the file where we are going to be adding our to-dos.

💡
If at any point you mess up your to-do list by testing out the app as you code along, you can delete this file and then the folder that contains it ("todos") on PodBrowser by clicking on the "Delete" button in the details drawer. Next time you refresh your app, the folder and file will be created again so you can start over.

Add an item to the dataset

Ok, now we can finally add a to-do! Adding a to-do is essentially adding an item, or Thing, to the to-do list dataset we just created. Let's write a function that does that so we can trigger it by clicking the button. Our to-dos will have two properties:

💡
We are hardcoding the predicate strings here, but there are libraries that make this easier, such as rdf-namespaces

The date will help us sort them later. So we need to create a thing and add these to it. We will use:

// components/AddTodo/index.js
import {
  addDatetime,
  addStringNoLocale,
  createThing,
  getSolidDataset,
  getSourceUrl,
  getThing,
  getUrlAll,
  saveSolidDatasetAt,
  setThing,
} from "@inrupt/solid-client";

function AddTodo() { 
const { session } = useSession();
// ...
  const addTodo = async (text) => {
    const indexUrl = getSourceUrl(todoList);
    const todoWithText = addStringNoLocale(
      createThing(),
      "http://schema.org/text",
      text
    );
    const todoWithDate = addDatetime(
      todoWithText,
      "http://www.w3.org/2002/12/cal/ical#created",
      new Date()
    );
    const todoWithType = addUrl(todoWithDate, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", "http://www.w3.org/2002/12/cal/ical#Vtodo");
    const updatedTodoList = setThing(todoList, todoWithType);
    const updatedDataset = await saveSolidDatasetAt(indexUrl, updatedTodoList, {
      fetch: session.fetch,
    });
    setTodoList(updatedDataset);
  };
// ...
}

We create the Thing first, add a string and a date, then set the thing in the dataset (todoList). We need to overwrite the todoList by saving it in its URL, which we get by using getSourceUrl. Now we need to modify our component so we can get the input text from the user, and let's put those predicate in constants to keep our code tidy and avoid bugs due to typos:

// components/AddTodo/index.js

import {
  addDatetime,
  addStringNoLocale,
  createThing,
  getSolidDataset,
  getSourceUrl,
  getUrlAll,
  saveSolidDatasetAt,
  setThing,
  getThing,
} from "@inrupt/solid-client";
import { useSession } from "@inrupt/solid-ui-react";
import React, { useEffect, useState } from "react";
import { getOrCreateTodoList } from "../../utils";

const STORAGE_PREDICATE = "http://www.w3.org/ns/pim/space#storage";
const TEXT_PREDICATE = "http://schema.org/text";
const CREATED_PREDICATE = "http://www.w3.org/2002/12/cal/ical#created";
const TODO_CLASS = "http://www.w3.org/2002/12/cal/ical#Vtodo";
const TYPE_PREDICATE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";

function AddTodo() {
  const { session } = useSession();
  const [todoList, setTodoList] = useState();
  const [todoText, setTodoText] = useState("");

  useEffect(() => {
    if (!session) return;
    (async () => {
      const profileDataset = await getSolidDataset(session.info.webId, {
        fetch: session.fetch,
      });
      const profileThing = getThing(profileDataset, session.info.webId);
      const podsUrls = getUrlAll(profileThing, STORAGE_PREDICATE);
      const pod = podsUrls[0];
      const containerUri = `${pod}todos/`;
      const list = await getOrCreateTodoList(containerUri, session.fetch);
      setTodoList(list);
    })();
  }, [session]);

  const addTodo = async (text) => {
    const indexUrl = getSourceUrl(todoList);
    const todoWithText = addStringNoLocale(createThing(), TEXT_PREDICATE, text);
    const todoWithDate = addDatetime(
      todoWithText,
      CREATED_PREDICATE,
      new Date()
    );
    const todoWithType = addUrl(todoWithDate, TYPE_CLASS, TODO_PREDICATE);
    const updatedTodoList = setThing(todoList, todoWithType);
    const updatedDataset = await saveSolidDatasetAt(indexUrl, updatedTodoList, {
      fetch: session.fetch,
    });
    setTodoList(updatedDataset);
  };

  const handleSubmit = async (event) => {
    event.preventDefault();
    addTodo(todoText);
  };

  const handleChange = (e) => {
    e.preventDefault();
    setTodoText(e.target.value);
  };

  return (
    <>
      <form onSubmit={handleSubmit} className="todo-form">
        <label htmlFor="todo-input">
          <input
            id="todo-input"
            type="text"
            value={todoText}
            onChange={handleChange}
          />
        </label>
        <button type="submit" className="add-button">
          Add Todo
        </button>
      </form>
    </>
  );
}

export default AddTodo;

Now if we write some text and click AddTodo, our to-do will be added! Only we cannot see our to-dos yet, so in order to check if it worked, on PodBrowser navigate to your "todos" folder, download the index.ttl file again, and see if there are changes. If everything went well, you should see something like this:

<https://pod.inrupt.com/virginiabalseiro/todos/index.ttl#16141957896165236259077375411>
        <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/12/cal/ical#Vtodo> ;
        <http://www.w3.org/2002/12/cal/ical#created>  "2021-02-24T19:43:09.616Z"^^xsd:dateTime ;
        <http://schema.org/text>  "Finish the Solid Todo App tutorial" .

You can see a random id has been generated for our to-do. This happens when we create a thing without passing a URL or a name string for the subject, which is fine for this case. Next we will see how we can fetch our to-dos so we can display them!

💡
Commit: 29f55e2

4 - Display to-dos