Learn Loopar
Welcome to the Loopar tutorials. These hands-on guides will take you from zero to building complete applications.
Learning Path
| Tutorial | Time | What You'll Build |
|---|---|---|
| 1. Your First App | 5 min | A complete app structure |
| 2. Creating Entities | 15 min | A Task Manager with CRUD |
| 3. Visual Page Design | 15 min | A landing page with drag & drop |
| 4. Custom Code | 20 min | Business logic and custom UI |
| 5. Working with Data | 15 min | Queries, filters, and relationships |
| 6. Forms & Validation | 15 min | Custom forms with validation |
| 7. Deploy to Production | 10 min | Create tenant and go live |
Prerequisites
Before starting, make sure you have:
- ā
Loopar installed and running (
npx loopar-install my-project) - ā
Access to
http://localhost:3000 - ā Completed the setup wizard
- ā Logged into the Desk
How to Use These Tutorials
Each tutorial:
- States the goal ā What you'll build
- Lists the steps ā Follow along in your Desk
- Shows the result ā What you should see
- Explains why ā Understanding the concepts
Tip: Keep your Loopar Desk open in one tab and this documentation in another. Follow along step by step.
Let's start building!
Tutorial 1: Your First App
Time: 5 minutes
Goal: Create your first Loopar application
What is an App?
An App in Loopar is a container for your modules and entities. Think of it as a project folder that groups related functionality.
Step 1: Open the Desk
Navigate to:
http://localhost:3000/desk
You should see the Desk interface with a sidebar menu.
Step 2: Create a New App
- In the sidebar, go to Core ā App
- Click the + New button
- Fill in the form:
| Field | Value |
|---|---|
| Name | task-manager |
| Description | A simple task management application |
- Click Save
Step 3: What Just Happened?
Loopar created:
/apps/
āāā task-manager/
āāā app.json # App metadata
āāā modules/
āāā task-manager/ # Default module (same name as app)
- ā A new app directory
- ā The app.json configuration file
- ā A default module with the same name
Step 4: Verify Your App
- Go to Core ā App in the sidebar
- You should see
task-managerin the list - Click on it to view its details
What You Learned
- Apps are the top-level containers in Loopar
- Creating an app automatically creates a default module
- All your entities will belong to this app through its modules
Next Step
Now that you have an app, let's create your first Entity to store data.
ā Continue to Tutorial 2: Creating Entities
Tutorial 2: Creating Entities
Time: 15 minutes
Goal: Create a Task entity with full CRUD functionality
What We're Building
A Task entity with:
- Title, description, status
- Due date and priority
- Auto-generated list view and form
Step 1: Create the Entity
- Go to Core ā Entity
- Click + New
- Fill in the basic info:
| Field | Value |
|---|---|
| Name | Task |
| Module | task-manager |
| Is Single | ā No (we want multiple tasks) |
Step 2: Add Fields (Drag & Drop)
Now let's add fields to our Task. In the form designer:
Add a Title field
- From the components panel, drag Input into the form area
- Click on it and set:
- Name:
title - Label:
Title - Required: ā Yes
Add a Description field
- Drag Textarea into the form
- Configure:
- Name:
description - Label:
Description
Add a Status field
- Drag Select into the form
- Configure:
- Name:
status - Label:
Status - Options:
Open\nIn Progress\nCompleted\nCancelled - Default:
Open
Add a Due Date field
- Drag Date into the form
- Configure:
- Name:
due_date - Label:
Due Date
Add a Priority field
- Drag Select into the form
- Configure:
- Name:
priority - Label:
Priority - Options:
Low\nMedium\nHigh\nUrgent - Default:
Medium
Step 3: Save the Entity
Click Save.
Loopar now creates:
- ā
Database table
Task - ā HTTP actions (list, view, create, update, delete)
- ā List view with filters
- ā Form for create/edit
- ā
Model class (
BaseDocument)
Step 4: Test Your Entity
View the List
- In the sidebar, find Task Manager ā Task
- You should see an empty list view
Create a Task
- Click + New
- Fill in the form:
- Title:
Learn Loopar - Description:
Complete all tutorials - Status:
In Progress - Due Date: (pick a date)
- Priority:
High
- Click Save
View in List
- Go back to the list
- Your task appears with all fields
- Try the search and filters!
Step 5: Explore the Generated Features
Automatic Features
| Feature | How to Access |
|---|---|
| Search | Type in the search box |
| Filters | Click filter icon, select status |
| Pagination | Bottom of the list |
| Edit | Click on any task |
| Delete | Select task ā Delete |
HTTP Actions (Automatic)
Every entity gets list, view, create, update and delete actions under /api/Task/.... They require a logged-in session (JWT cookie + CSRF header for POST) ā the desk UI and the client loopar.call handle that for you:
# While logged in (cookies + CSRF), these JSON endpoints exist:
# GET /api/Task/list
# GET /api/Task/view?name=TASK-0001
# POST /api/Task/create (body = field values)
# POST /api/Task/update?name=TASK-0001
Tutorial 4 shows how to call them with curl and how to add your own actions.
What You Learned
- Entities define data models visually
- Drag & drop to add fields
- Loopar generates everything: database, API, UI
- Each entity gets a full CRUD interface
Challenge
Try adding more fields:
- Assigned To (Input field)
- Is Completed (Switch field)
- Estimated Hours (Decimal field)
ā Continue to Tutorial 3: Visual Page Design
Tutorial 3: Visual Page Design
Time: 15 minutes
Goal: Create a landing page using drag & drop
What We're Building
A landing page for our Task Manager app with:
- Hero section with title and CTA
- Features section
- Call to action
Step 1: Create a New Page
- Go to Core ā Page
- Click + New
- Fill in:
| Field | Value |
|---|---|
| Name | task-manager-home |
| Module | task-manager |
| Title | Task Manager - Get Things Done |
Step 2: Build the Hero Section
Add a Section
- From components, drag Section into the page
- This is your full-width container
Add a Row and Columns
- Inside the section, drag a Row
- Configure layout:
[50, 50](two equal columns)
Left Column - Text Content
- Click on the left column
- Drag a Title into it:
- Text:
Manage Tasks Like a Pro - Class:
display-4 mb-4
- Drag a Paragraph below:
- Text:
Simple, powerful task management for teams and individuals. Stay organized, meet deadlines, achieve goals. - Class:
text-muted text-lg mb-6
- Drag a Link (button):
- Label:
Get Started Free - To:
/desk/Task/list - Variant:
default
Right Column - Image
- Click on the right column
- Drag an Image:
- Background Image: (paste a URL or upload)
- Aspect Ratio:
16:9 - Background Size:
contain
Step 3: Build the Features Section
Add Another Section
- Drag a new Section below the hero
- Add a Text Block centered:
- Subtitle:
Features - Title:
Everything you need to stay productive
Add Feature Cards
- Drag a Row with layout
[33, 33, 33] - In each column, drag a Feature Card:
Card 1:
- Icon:
CheckSquare - Title:
Task Tracking - Description:
Create, organize, and track tasks with ease
Card 2:
- Icon:
Calendar - Title:
Due Dates - Description:
Never miss a deadline with smart reminders
Card 3:
- Icon:
Users - Title:
Team Collaboration - Description:
Work together seamlessly with your team
Step 4: Add a CTA Section
- Drag a new Section
- Add a Panel with:
- Variant:
gradient - Class:
text-center p-12
- Inside the panel:
- Title:
Ready to get organized? - Paragraph:
Start managing your tasks today. It's free. - Link:
Start Nowā/desk/Task/create
Step 5: Save and Preview
- Click Save
- Open a new tab:
http://localhost:3000/task-manager-home - See your landing page live!
Step 6: Add Animations (Optional)
Make it fancy:
- Select any element
- In properties, set:
- Animation:
fade-up,fade-right,zoom-in - Animation Delay:
100,200,300(stagger effect)
Page Structure Recap
Page: task-manager-home
āāā Section (Hero)
ā āāā Row [50, 50]
ā āāā Col: Title + Paragraph + Link
ā āāā Col: Image
āāā Section (Features)
ā āāā Text Block (centered header)
ā āāā Row [33, 33, 33]
ā āāā Feature Card 1
ā āāā Feature Card 2
ā āāā Feature Card 3
āāā Section (CTA)
āāā Panel
āāā Title + Paragraph + Link
What You Learned
- Pages are built with Sections, Rows, and Columns
- Drag & drop any component
- Configure properties for styling
- Pages get automatic routes (
/page-name) - Animations add polish
Challenge
Add more to your page:
- A testimonials section
- A pricing table
- A contact form
ā Continue to Tutorial 4: Custom Code
Tutorial 4: Custom Code
Time: 20 minutes Goal: Add business logic and custom UI to your entity
When You Need Custom Code
The visual builder handles 80% of cases. For the rest:
- Model (.js): Business logic, hooks, helper methods (NOT URL accessible)
- Controller (-controller.js): API actions (URL accessible via
action*methods) - Client (client/*.jsx): Custom UI, interactions, real-time updates
Understanding Model vs Controller
| File | Purpose | URL Accessible |
|---|---|---|
task.js | Business logic, hooks, data methods | ā No |
task-controller.js | API endpoints | ā
Yes (only action* / publicAction* methods) |
client/task-form.jsx | React UI component | ā (client-side) |
Part A: Server-Side Logic
Step 1: Locate Your Entity Files
Your Task entity files are at:
/apps/task-manager/modules/task-manager/entities/task/
āāā task.json # Field definitions (already exists)
āāā task.js # Model - business logic (create this)
āāā task-controller.js # Controller - API actions (create this)
āāā client/
āāā task-form.jsx # Client-side React (create this)
Step 2: Create the Model
Create task.js ā this handles business logic and data:
import { BaseDocument, loopar } from "loopar";
export default class Task extends BaseDocument {
constructor(props) {
super(props);
}
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// HELPER METHODS (not URL accessible)
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
getFullTitle() {
return `[${this.priority}] ${this.title}`;
}
isOverdue() {
if (!this.due_date || this.status === "Completed") return false;
return new Date(this.due_date) < new Date();
}
getDaysUntilDue() {
if (!this.due_date) return null;
const diff = new Date(this.due_date) - new Date();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// VALIDATION
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
async validate() {
await super.validate(); // keep the metadata-driven validation
if (this.title && this.title.length < 3) {
loopar.throw("Title must be at least 3 characters");
}
if (this.__IS_NEW__ && this.due_date) {
const dueDate = new Date(this.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (dueDate < today) {
loopar.throw("Due date cannot be in the past");
}
}
if (this.priority === "Urgent" && !this.due_date) {
loopar.throw("Urgent tasks must have a due date");
}
}
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// LIFECYCLE HOOKS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
async beforeSave() {
if (this.__IS_NEW__ && !this.status) {
this.status = "Open";
}
// Auto-set completed_date when status changes to Completed
if (this.status === "Completed" && !this.completed_date) {
this.completed_date = new Date();
}
// Clear completed_date if reopened
if (this.status !== "Completed") {
this.completed_date = null;
}
}
async afterSave() {
console.log(`Task ${this.name} saved with status: ${this.status}`);
}
}
Step 3: Create the Controller
Create task-controller.js ā only action* methods are URL accessible. The URL segment is the literal method name without the prefix (no kebab-case):
import { BaseController, loopar } from "loopar";
export default class TaskController extends BaseController {
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// URL ACCESSIBLE ACTIONS
// Only methods starting with 'action' are accessible via URL
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// POST /api/Task/markComplete
async actionMarkComplete() {
const task = await loopar.getDocument("Task", this.data.name);
task.status = "Completed";
task.completed_date = new Date();
await task.save();
return this.success(`Task "${task.title}" marked as complete`);
}
// POST /api/Task/markUrgent
async actionMarkUrgent() {
const task = await loopar.getDocument("Task", this.data.name);
task.priority = "Urgent";
await task.save();
return this.success(`Task "${task.title}" marked as urgent`);
}
// GET/POST /api/Task/stats
async actionStats() {
const open = await loopar.db.count("Task", { status: "Open" });
const inProgress = await loopar.db.count("Task", { status: "In Progress" });
const completed = await loopar.db.count("Task", { status: "Completed" });
return {
open,
inProgress,
completed,
total: open + inProgress + completed
};
}
// GET/POST /api/Task/overdue
async actionOverdue() {
const tasks = await loopar.db.getAll(
"Task",
["name", "title", "status", "due_date"],
{ status: ["Open", "In Progress"] } // bare array = IN
);
const overdue = tasks.filter(t => {
if (!t.due_date) return false;
return new Date(t.due_date) < new Date();
});
return { overdue };
}
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// PRIVATE HELPER METHODS (NOT URL accessible)
// Methods without 'action' prefix are internal only
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
formatTaskForResponse(task) {
return {
name: task.name,
title: task.title,
status: task.status
};
}
}
Step 4: Test Your Custom Logic
- Restart your dev server (or wait for hot reload)
- Try creating a task with a 2-character title ā Error!
- Try setting a past due date ā Error!
- Create a task, then set status to Completed ā
completed_dateauto-fills
Test Controller Actions
action* methods require an authenticated session (JWT cookie + CSRF header for POST). The easiest way to test is from the browser while logged in, or from your client code with loopar.call (below). With curl, log in first:
# 1. Login and save the cookies
curl -c cookies.txt -X POST http://localhost:3000/auth/login \
-d "email=admin@example.com&password=secret"
# 2. Get stats
curl -b cookies.txt http://localhost:3000/api/Task/stats
# 3. Mark a task complete (POST needs the CSRF header)
CSRF=$(grep csrf-token cookies.txt | awk '{print $7}')
curl -b cookies.txt -H "x-csrf-token: $CSRF" \
-X POST http://localhost:3000/api/Task/markComplete \
-H "Content-Type: application/json" \
-d '{"name": "TASK-0001"}'
Need an endpoint that works without login (e.g. a webhook)? Name it
publicActionSomethingā public actions skip auth and CSRF.
Part B: Client-Side UI
Step 1: Create the React View
Create client/task-form.jsx ā extend the form context and add your custom behavior:
import FormContext from "@context/form-context";
import { loopar } from "loopar";
export default class TaskForm extends FormContext {
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// QUICK ACTIONS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
markComplete() {
this.setValue("status", "Completed");
this.save();
}
markUrgent() {
this.setValue("priority", "Urgent");
this.save();
}
// Or call a controller action directly:
async refreshStats() {
const stats = await loopar.call("Task", "stats");
loopar.notify(`${stats.open} open tasks`, "info");
}
}
The default form fields keep rendering automatically ā your class only adds behavior on top.
Step 2: See It in Action
- Go to Task Manager ā Task
- Click on any task or create a new one
- Your custom logic is live!
Summary: Model vs Controller
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā SERVER-SIDE ARCHITECTURE ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā ā
ā task.js (MODEL) ā
ā āāā getFullTitle() ā Helper method (internal) ā
ā āāā isOverdue() ā Helper method (internal) ā
ā āāā validate() ā Lifecycle hook ā
ā āāā beforeSave() ā Lifecycle hook ā
ā āāā afterSave() ā Lifecycle hook ā
ā ā
ā task-controller.js (CONTROLLER) ā
ā āāā actionMarkComplete()ā POST /api/Task/markComplete ā
ā āāā actionStats() ā GET /api/Task/stats ā
ā āāā formatTask() ā Private helper (no URL access) ā
ā ā
ā RULE: Only methods starting with 'action' are URL routes ā
ā ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
What You Learned
- Model (.js): Business logic, hooks, helper methods ā NOT URL accessible
- Controller (-controller.js): Only
action*methods are URL accessible; URL = literal method name (actionMarkCompleteāmarkComplete) - Client (client/*.jsx): Custom React UI extending the form/list/view contexts
- The controller calls model methods to get processed data
Code Location Summary
| Need | File | URL Accessible |
|---|---|---|
| Validation rules | Model .js | ā No |
| Helper methods | Model .js | ā No |
| Lifecycle hooks | Model .js | ā No |
| API endpoints | Controller -controller.js | ā
Only action* |
| Custom UI | client/*.jsx | ā |
ā Continue to Tutorial 5: Working with Data
Tutorial 5: Working with Data
Time: 15 minutes Goal: Query data, create relationships, and work with the database API
What We're Building
We'll extend our Task Manager with:
- A Project entity (to group tasks)
- Relationship between Project and Task
- Queries to fetch related data
Step 1: Create the Project Entity
- Go to Core ā Entity ā + New
- Configure:
| Field | Value |
|---|---|
| Name | Project |
| Module | task-manager |
- Add fields:
- name (Input, Required)
- description (Textarea)
- status (Select:
Active,On Hold,Completed) - start_date (Date)
- end_date (Date)
- Save the entity
Step 2: Link Task to Project
- Open the Task entity for editing
- Add a new Select field pointing at the Project entity:
- Name:
project - Label:
Project - Options:
Project
- Save the Task entity
Now each task can belong to a project!
Step 3: Create Project Model
Create project.js (in modules/task-manager/entities/project/) for project-specific logic:
import { BaseDocument, loopar } from "loopar";
export default class Project extends BaseDocument {
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// HELPER METHODS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
async getTaskCount() {
return await loopar.db.count("Task", { project: this.name });
}
async getCompletedTaskCount() {
return await loopar.db.count("Task", {
project: this.name,
status: "Completed"
});
}
async getProgress() {
const total = await this.getTaskCount();
if (total === 0) return 0;
const completed = await this.getCompletedTaskCount();
return Math.round((completed / total) * 100);
}
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// VALIDATION
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
async validate() {
await super.validate();
if (this.start_date && this.end_date) {
if (new Date(this.end_date) < new Date(this.start_date)) {
loopar.throw("End date must be after start date");
}
}
}
}
Step 4: Create Project Controller
Create project-controller.js:
import { BaseController, loopar } from "loopar";
export default class ProjectController extends BaseController {
// GET /api/Project/tasks?name=PROJECT-001
async actionTasks() {
const tasks = await loopar.db.getAll(
"Task",
["name", "title", "status", "priority", "due_date"],
{ project: this.query.name }
);
// Order in JS (or use loopar.db.raw for SQL-level ordering)
tasks.sort((a, b) => new Date(a.due_date || 0) - new Date(b.due_date || 0));
return {
project: this.query.name,
tasks
};
}
// GET /api/Project/stats?name=PROJECT-001
async actionStats() {
const project = await loopar.getDocument("Project", this.query.name);
const tasks = await loopar.db.getAll(
"Task",
["name", "status", "priority", "due_date"],
{ project: project.name }
);
const byStatus = {};
const byPriority = {};
tasks.forEach(task => {
byStatus[task.status] = (byStatus[task.status] || 0) + 1;
byPriority[task.priority] = (byPriority[task.priority] || 0) + 1;
});
// Calculate overdue tasks
const today = new Date();
const overdue = tasks.filter(t =>
t.due_date &&
new Date(t.due_date) < today &&
t.status !== "Completed"
).length;
return {
project: project.name,
total: tasks.length,
progress: await project.getProgress(),
byStatus,
byPriority,
overdue
};
}
// POST /api/Project/checkCompletion
async actionCheckCompletion() {
const project = await loopar.getDocument("Project", this.data.name);
const incompleteTasks = await loopar.db.count("Task", {
project: project.name,
status: ["Open", "In Progress"] // bare array = IN
});
if (incompleteTasks === 0) {
project.status = "Completed";
await project.save();
return this.success("Project completed!", { completed: true });
}
return { completed: false, remaining: incompleteTasks };
}
}
Step 5: Database API Reference
Common database operations:
import { loopar, Op } from "loopar";
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// GET DOCUMENTS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// Get single document by name (full model, with hooks & methods)
const task = await loopar.getDocument("Task", "TASK-0001");
// Get all rows (raw, fast): getAll(entity, fields?, filter?)
const allTasks = await loopar.db.getAll("Task");
// Selected fields + filters
const urgentTasks = await loopar.db.getAll(
"Task",
["name", "title", "due_date"],
{
priority: "Urgent",
status: ["Open", "In Progress"] // bare array = IN
}
);
// Operators
const recent = await loopar.db.getAll("Task", ["name", "title"], {
created_at: { [Op.gte]: "2026-01-01" },
title: { [Op.like]: "%urgent%" }
});
// Paginated (respects loopar.db.pagination / setPage)
const page = await loopar.db.getList("Task", ["name", "title"], { status: "Open" });
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// COUNT DOCUMENTS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
const total = await loopar.db.count("Task", {});
const openCount = await loopar.db.count("Task", { status: "Open" });
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// CREATE DOCUMENTS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
const newTask = await loopar.newDocument("Task");
newTask.title = "New Task";
newTask.status = "Open";
newTask.project = "PROJECT-001";
await newTask.save();
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// UPDATE DOCUMENTS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
const task = await loopar.getDocument("Task", "TASK-0001");
task.status = "Completed";
await task.save();
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// DELETE DOCUMENTS
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
const task = await loopar.getDocument("Task", "TASK-0001");
await task.delete();
What You Learned
- A Select pointing at an entity creates relationships between entities
- Model has helper methods (not URL accessible)
- Controller has
action*methods (URL accessible) loopar.getDocument()fetches a single document with its modelloopar.db.getAll(entity, fields, filter)queries raw rowsloopar.db.count()counts matching documents; bare arrays meanIN
ā Continue to Tutorial 6: Forms & Validation
Tutorial 6: Forms & Validation
Time: 15 minutes
Goal: Create standalone forms with validation
When to Use Standalone Forms
- Login/registration forms
- Contact forms
- Search forms
- Multi-step wizards
- Forms that don't map directly to an entity
Step 1: Create a Contact Form
- Go to Core ā Form ā + New
- Configure:
| Field | Value |
|---|---|
| Name | contact-form |
| Module | task-manager |
| Title | Contact Us |
- Add fields (drag & drop):
- name (Input, Required, Label: "Your Name")
- email (Input with format
email, Required, Label: "Email Address") - subject (Input, Required, Label: "Subject")
- message (Textarea, Required, Label: "Message")
- Save the form
Step 2: Create the Form Controller
Create contact-form-controller.js to handle submission:
import { BaseController } from "loopar";
export default class ContactFormController extends BaseController {
// POST /api/contact-form/submit
// publicAction* = works without login (this is a public contact form)
async publicActionSubmit() {
const { name, email, subject, message } = this.data;
// Validation
const errors = [];
if (!name || name.length < 2) {
errors.push("Name must be at least 2 characters");
}
if (!email || !this.isValidEmail(email)) {
errors.push("Please provide a valid email address");
}
if (!subject || subject.length < 5) {
errors.push("Subject must be at least 5 characters");
}
if (!message || message.length < 20) {
errors.push("Message must be at least 20 characters");
}
if (errors.length > 0) {
return {
success: false,
errors
};
}
// Process the form
await this.processContactForm({ name, email, subject, message });
return {
success: true,
message: "Thank you for your message! We'll get back to you soon."
};
}
// Private helper (no 'action'/'publicAction' prefix = not URL accessible)
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Private helper
async processContactForm(data) {
console.log("Contact form received:", data);
// Save to database, send email, etc.
}
}
Step 3: Entity-Level Validation
For entity forms, add validation in the Model .js file:
// task.js (MODEL)
import { BaseDocument, loopar } from "loopar";
export default class Task extends BaseDocument {
async validate() {
await super.validate(); // keep the metadata-driven validation
const errors = [];
// Required
if (!this.title) {
errors.push("Title is required");
}
// Length
if (this.title && this.title.length < 3) {
errors.push("Title must be at least 3 characters");
}
// Business rules
if (this.priority === "Urgent" && !this.due_date) {
errors.push("Urgent tasks must have a due date");
}
if (errors.length > 0) {
loopar.throw(errors.join("\n"));
}
}
}
Validation Patterns
| Pattern | Model (.js) | Controller (-controller.js) |
|---|---|---|
| Entity validation | ā
validate() hook | ā |
| Form validation | ā | ā
In action* method |
| Business rules | ā Always | ā |
| API input validation | ā | ā Always |
What You Learned
- Standalone forms use Form Builder + controller
- Entity validation goes in the Model's
validate()method - Form validation goes in the Controller's
action*method - Only
action*methods in controllers are URL accessible
ā Continue to Tutorial 7: Deploy to Production
Tutorial 7: Deploy to Production
Time: 10 minutes
Goal: Create a tenant and deploy your app to production
What We're Doing
- Create a new tenant for a "client"
- Install our Task Manager app
- Configure a domain
- Deploy to production with SSL
Step 1: Create a New Tenant
- In your dev site, go to Core ā Tenant
- Click + New
- Configure:
| Field | Value |
|---|---|
| Name | acme-corp |
| Port | 3001 |
| Database Type | MySQL (or SQLite for testing) |
| Database Name | acme_corp_db |
- Click Save
Loopar creates:
- ā
New directory:
sites/acme-corp/ - ā
Environment file:
sites/acme-corp/.env - ā PM2 process configuration
Step 2: Start the Tenant
- In the tenant list, find
acme-corp - Click Start
- The tenant starts on port 3001
Access it at:
http://localhost:3001
You'll see the setup wizard for this new tenant.
Step 3: Complete Tenant Setup
- Open
http://localhost:3001 - Complete the setup wizard:
- Database configuration
- Admin account
- Project info
- Log in to the tenant's Desk
Step 4: Install Your App
- In the tenant's Desk, go to Core ā App Manager
- Find
task-managerin the available apps - Click Install
The app is now installed on this tenant with:
- ā All entities (Task, Project)
- ā All pages
- ā All modules
Step 5: Configure Domain (Production)
For production deployment, you need:
- A domain pointing to your server
- Caddy running on the server
Update Tenant Domain
- Go back to your dev site
- Open Core ā Tenant ā
acme-corp - Set the domain:
| Field | Value |
|---|---|
| Domain | tasks.acme-corp.com |
| Production | ā Yes |
- Click Save
Step 6: Deploy to Production
- In the tenant record, click Set on Production (or Deploy)
- Loopar will:
- Register the domain with Caddy
- Obtain SSL certificate (Let's Encrypt)
- Restart tenant with
NODE_ENV=production - Enable production optimizations
Step 7: Verify Deployment
# Check process status (project-local PM2 daemon)
yarn list
# Check Caddy routes
curl http://localhost:2019/config/apps/http/servers/srv0/routes
Your site is now live at https://tasks.acme-corp.com!
Congratulations! š
You've completed all the Loopar tutorials!
You now know how to:
- ā Create apps and modules
- ā Build entities with CRUD
- ā Design pages visually
- ā Add custom business logic (Model vs Controller)
- ā Query and relate data
- ā Create forms with validation
- ā Deploy to production
Key Architecture Recap
/entity-name/
āāā entity-name.json # Field definitions
āāā entity-name.js # MODEL: hooks, helpers (not URL accessible)
āāā entity-name-controller.js # CONTROLLER: action* methods (URL accessible)
āāā entity-name.jsx # CLIENT: React component
Next Steps
- Explore the Components Reference
- Check the API Reference
- Join our GitHub community
Happy building! š