Todo list with JSON views
In this guide I'll show you how to add Lenra to an existing app. We'll use the Todo-React app from MDN as an example.
Download the code and start tweak it following the guide.
Initialize Lenra app
Add Lenra client lib to your project and initialize Lenra app
# Add the client lib
npm i @lenra/client
# Initialize the lenra app
lenra new -p app-lenra js
That will add the @lenra/client
lib to your project and create the app-lenra
folder that contain the backend lenra app. You can move the lenra.yml
to the root of your repo and add the key path
at the root of the file.
path: app-lenra
generator:
dofigen:
[...]
Create the Lenra client elements
Create the Lenra app object and initialize it in the index.js
file
import { LenraApp } from '@lenra/client';
const lenraApp = new LenraApp({
clientId: "XXX-XXX-XXX",
});
Create a react component that will show a button to login to Lenra that take as params the callback function onClick
that will be called when the button is clicked. Which will handle the login to Lenra.
import React from "react";
function LoginButton(props) {
return (
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}} >
<button onClick={props.onClick} style={{
border: "1px solid black",
padding: "5px",
margin: "10px 0px"
}}>
Login
</button>
</div >
);
}
export default LoginButton;
In order to make the app code more readable, I'll move the App.js
file content into a new List.js
file (be careful to update the imports. So the App.js
will be the main component that will handle the initialization of the lenra app and List.js
will just contain the view that list todos.
You can also remove the className
field of the returned <div>
of the List.js
:
return (
<div>
...
</div>
);
The new content of the App.js
file that will handle the initialization of the lenra app look like this:
import React, { useState } from "react";
import LoginButton from './components/LoginButton'
import List from './List'
const DATA = [
{ id: "todo-0", name: "Eat", completed: true },
{ id: "todo-1", name: "Sleep", completed: false },
{ id: "todo-2", name: "Repeat", completed: false },
];
/**
* @param {{ app: LenraApp }} props
*/
function App({ app }) {
/**
* @type {[LenraSocket, (value: LenraSocket) => void]}
*/
const [socket, setSocket] = useState(null);
return (
<div className="todoapp stack-large">
{socket ? (
<List tasks={DATA}/>
) : (
<LoginButton onClick={() => {
app.connect().then((value) => {
setSocket(value);
});
}}
/>
)}
</div >
);
}
export default App;
We can now adapt the index.js
file to use the App
component.
import React from "react";
import ReactDOM from "react-dom/client";
import { LenraApp } from '@lenra/client';
import App from "./App";
import "./index.css";
if (window.location.pathname === "/redirect.html") {
window.opener.postMessage(window.location.href, `${window.location.protocol}//${window.location.host}`);
}
else {
const lenraApp = new LenraApp({
clientId: "XXX-XXX-XXX",
});
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App app={lenraApp} />
</React.StrictMode>
);
}
Connect to the todo route
In the List.js
, you can use the Lenra socket to connect to the routes you'll later define in the Lenra app manifest.js
.
In this example, we'll first connect to the /todos
route using the router. React need states variables to be updated in order to re-render the component. So we'll use theuseState
hook to store the routes and the todos and filters objects that will contains the json of each routes which will be able to use later in theList
component.
/**
* @param {{ socket: LenraSocket }} props
*/
function List(props) {
const [filter, setFilter] = useState("All");
const [todosRoute, setTodosRoute] = useState(null);
const [jsonView, setJsonView] = useState({});
const [tasks, setTasks] = useState([]);
const { socket } = props;
useEffect(() => {
const todosRoute = socket?.route("/todos", (json) => {
console.log('Get todos', json)
setJsonView(json);
setTasks(json.todos);
});
setTodosRoute(todosRoute);
}, [socket]);
[...]
}
You will update the App component to pass the socket to the List
component.
return (
<div className="todoapp stack-large">
{socket ? (
<List socket={socket}/>
) : (
<LoginButton onClick={() => {
app.connect().then((value) => {
setSocket(value);
});
}}
/>
)}
</div >
);
You can now update the List
component to use thoses json parameters that describe your UI's data.
In this example, I just copy pasted the code from the original List.js
file and replaced the static data by the json data from the routes.
I also removed all setState
call because it's not needed anymore here.
To handle the events, I used the callListener
method of the LenraRoute
object that will call the listener of the route with the event object as parameter if needed.
Example of adaptation of the editTask function in the List.js
file :
function editTask(id, newName) {
const task = tasks.find(task => task.id === id);
todosRoute.callListener({...task.onEdit, event: { value: newName }});
}
Here the full code of this List.js
file if needed :
import React, { useState, useRef, useEffect } from "react";
import Form from "./Form";
import FilterButton from "./FilterButton";
import Todo from "./Todo";
import { nanoid } from "nanoid";
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
const FILTER_MAP = {
All: () => true,
Active: (task) => !task.completed,
Completed: (task) => task.completed,
};
const FILTER_NAMES = Object.keys(FILTER_MAP);
/**
* @param {{ socket: LenraSocket }} props
*/
function List(props) {
const [filter, setFilter] = useState("All");
const [todosRoute, setTodosRoute] = useState(null);
const [jsonView, setJsonView] = useState({});
const [tasks, setTasks] = useState([]);
const { socket } = props;
useEffect(() => {
const todosRoute = socket?.route("/todos", (json) => {
console.log('Get todos', json)
setJsonView(json);
setTasks(json.tasks);
});
setTodosRoute(todosRoute);
}, [socket]);
function toggleTaskCompleted(id) {
const task = tasks.find(task => task.id === id);
todosRoute.callListener(task.onToggle);
}
function deleteTask(id) {
const task = tasks.find(task => task.id === id);
todosRoute.callListener(task.onDelete);
}
function editTask(id, newName) {
const task = tasks.find(task => task.id === id);
todosRoute.callListener({...task.onEdit, event: { value: newName }});
}
const taskList = tasks
.filter(FILTER_MAP[filter])
.map((task) => (
<Todo
id={task.id}
name={task.name}
completed={task.completed}
key={task.id}
toggleTaskCompleted={toggleTaskCompleted}
deleteTask={deleteTask}
editTask={editTask}
/>
));
const filterList = FILTER_NAMES.map((name) => (
<FilterButton
key={name}
name={name}
isPressed={name === filter}
setFilter={setFilter}
/>
));
function addTask(name) {
todosRoute.callListener({...jsonView.addTask, event: { value: name }});
}
const tasksNoun = taskList.length !== 1 ? "tasks" : "task";
const headingText = `${taskList.length} ${tasksNoun} remaining`;
const listHeadingRef = useRef(null);
const prevTaskLength = usePrevious(tasks.length);
useEffect(() => {
if (tasks.length - prevTaskLength === -1) {
listHeadingRef.current.focus();
}
}, [tasks.length, prevTaskLength]);
return (
<div>
<Form addTask={addTask} />
<div className="filters btn-group stack-exception">{filterList}</div>
<h2 id="list-heading" tabIndex="-1" ref={listHeadingRef}>
{headingText}
</h2>
<ul
className="todo-list stack-large stack-exception"
aria-labelledby="list-heading">
{taskList}
</ul>
</div>
);
}
export default List;
Start the react app:
npm i
npm start
In another terminal, start the lenra devtool
lenra dev
Open the react app and click on the login button when the devtool is healthy. You should see a popup openning and quickly closing itself. If you see this, you are connected to Lenra.
Now that you can successfully connected to your app, we can start to write code of the backend part of the app.
Lenra app
Now that our client app is connected to Lenra, we can start to write the backend part of the app. We'll first remove the template code of the app-lenra
folder:
rm -rf app-lenra/src/views/lenra/
rm -f app-lenra/src/views/counter.js
rm -f app-lenra/src/listeners/increment.js
We also will empty the system listeners functions in the app-lenra/src/listeners/systemEvents.js
file:
/**
*
* @param {import("@lenra/app").} _props
* @param {import("@lenra/app").event} _event
* @param {import("@lenra/app").Api} api
*/
export async function onEnvStart(_props, _event, _api) {
}
export async function onUserFirstJoin(_props, _event, _api) {
}
export async function onSessionStart(_props, _event, _api) {
}
The Todo class
In the app-lenra
folder, we will first create the Todo
class as our data model in the src/classes/Todo.js
.
This extends the Data
class from the @lenra/app
lib that allow the class to be stored in the database as documents with each fields as properties of the document.
import { Data } from "@lenra/app";
export class Todo extends Data {
/**
*
* @param {string} user
* @param {string} name
* @param {boolean} completed
*/
constructor(user, name, completed = false) {
super();
this.user = user;
this.name = name;
this.completed = completed;
}
}
The todos view
Now we'll define the todos view that will handle the data of the app.
The first parameter of the function will be an array of the data returned by the query of the view. The second parameter will be the props passed to the view in the manifest.
We can with that return the data we need to display in the UI in your app, but also define some listeners call that will be called when the user will interact with your app using the Listeners
class from the @lenra/app
lib. The props passed to the listener will allow you to pass data to the listener call without even letting the user know about it. (No data is sent to the client app)
This view will return an object with the tasks
array that will contain the todos data, on each todos we'll add a listener to update it's state or to delete it. And the addTask
listener that will be called when the user will add a new todo.
import { Listener } from "@lenra/app";
import { Todo } from "../classes/Todo.js";
/**
*
* @param {Todo[]} param0
* @param {*} _props
* @returns {import("@lenra/app").JsonViewResponse}
*/
export default function todos (todos, _props) {
return {
tasks: todos.map((todo) => ({
id: todo._id,
name: todo.name,
completed: todo.completed,
onToggle: Listener("toggleTodo")
.props({
id: todo._id,
state: !todo.state
}),
onDelete: Listener("deleteTodo").props({
id: todo._id
}),
onEdit: Listener("editTodo").props({
id: todo._id
})
})),
addTask: Listener("addTodo")
};
}
Update of the manifest
We'll now update the manifest.js
file that will define each accessible routes of the app.
The View()
function allow you to define a view call with a query that will be used to filter the data of the view. You can use the @me
keyword to get the current user id.
import { View } from "@lenra/app";
import { Todo } from "./classes/Todo.js";
/**
* @type {import("@lenra/app").Manifest["json"]}
*/
export const json = {
routes: [
{
path: "/todos",
view: View("todos").find(Todo, {
"user": "@me"
})
}
]
};
Create the listeners
Now that we have the view that will handle the data of the app, we'll create the listeners that will handle the events of the app.
Let's start with the addTodo
listener:
import { Todo } from "../classes/Todo.js";
/**
*
* @param {import("@lenra/app").props} props
* @param {import("@lenra/app").event} _event
* @param {import("@lenra/app").Api} api
* @returns
*/
export default async function addTodo (_props, event, api) {
const todo = new Todo("@me", event.value);
await api.data.coll(Todo).createDoc(todo);
}
Reload your Lenra app and refresh your browser. You should be able to add todos to your list.
Now we'll create the toggleTodo
listener:
import { Todo } from "../classes/Todo.js";
/**
*
* @param {import("@lenra/app").props} props
* @param {import("@lenra/app").event} _event
* @param {import("@lenra/app").Api} api
* @returns
*/
export default async function toggleTodo(props, _event, api) {
const transaction = await api.data.startTransaction();
const coll = transaction.coll(Todo);
const todo = await coll.getDoc(props.id);
todo.completed = !todo.completed;
await coll.updateDoc(todo);
await transaction.commit();
}
And the deleteTodo
listener:
import { Todo } from "../classes/Todo.js";
/**
*
* @param {import("@lenra/app").props} props
* @param {import("@lenra/app").event} _event
* @param {import("@lenra/app").Api} api
* @returns
*/
export default async function deleteTodo (props, event, api) {
await api.data.coll(Todo).deleteDoc({ _id: props.id });
}
And the editTodo
listener:
import { Todo } from "../classes/Todo.js";
/**
*
* @param {import("@lenra/app").props} props
* @param {import("@lenra/app").event} _event
* @param {import("@lenra/app").Api} api
* @returns
*/
export default async function editTodo(props, event, api) {
const transaction = await api.data.startTransaction();
const coll = transaction.coll(Todo);
const todo = await coll.getDoc(props.id);
todo.name = event.value;
await coll.updateDoc(todo);
await transaction.commit();
}
And your app works !