From frappe-fullstack
Provides React SPA patterns for Frappe backend integration using TanStack Query, Axios, Jotai, shadcn/ui, Tailwind, Refine v4, Mantine v5, React Router, Vite, from Unity Parent App and Walsh Admin Portal.
npx claudepluginhub unityappsuite/frappe-claude --plugin frappe-fullstackThis skill uses the workspace's default tool permissions.
Quick reference for building React SPAs that integrate with a Frappe backend.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Quick reference for building React SPAs that integrate with a Frappe backend.
| App | Path | Stack |
|---|---|---|
| Unity Parent App | apps/unity_parent_app/new_frontend/ | React 18 + Vite 6 + shadcn/ui + Tailwind + TanStack Query v5 + Jotai + Axios |
| Walsh Admin Portal | apps/edu_quality/walsh/ | React 18 + Vite 5 + Refine v4 + Mantine v5 + React Query v3 |
// src/utils/axiosInstance.ts — pre-configured with withCredentials: true
import axiosInstance from '@/utils/axiosInstance';
// Call a whitelisted method
const res = await axiosInstance.post(
'/api/method/app.module.function',
{ student_id: 'STU-001' }
);
const data = res.data.message; // Frappe wraps in .message
// Fetch a resource
const res = await axiosInstance.get('/api/resource/Student/STU-001');
const student = res.data.data;
import { useList, useOne, useCreate, useUpdate, useDelete } from "@refinedev/core";
// List
const { data } = useList({
resource: "Student",
filters: [{ field: "enabled", operator: "eq", value: 1 }],
pagination: { pageSize: 50 },
meta: { dataProviderName: "default" }, // or "notices", "cmap"
});
// data.data = array of records
// Create
const { mutate: create } = useCreate();
create({ resource: "Student", values: { ... } });
// Custom method (not REST)
const res = await dataProvider.custom({
url: '/api/method/edu_quality.api.module.function',
method: 'post',
payload: { param: value },
});
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Query
const { data, isLoading, error } = useQuery({
queryKey: ['students', academicYear],
queryFn: () =>
axiosInstance.post('/api/method/...', { academic_year: academicYear })
.then(r => r.data.message),
staleTime: 5 * 60 * 1000,
enabled: !!academicYear,
});
// Mutation with cache invalidation
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: (data) => axiosInstance.post('/api/method/...', data).then(r => r.data.message),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['students'] }),
});
import { useQuery, useMutation } from 'react-query';
const { data } = useQuery(
['key', dep],
() => fetch('/api/method/...').then(r => r.json()).then(r => r.message),
{ staleTime: 60_000 }
);
// Define atom
import { atom } from 'jotai';
export const activeStudentAtom = atom<string | null>(null);
// Read + write in component
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
const [student, setStudent] = useAtom(activeStudentAtom);
const student = useAtomValue(activeStudentAtom); // read-only
const setStudent = useSetAtom(activeStudentAtom); // write-only
// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
<BrowserRouter basename="/parent-app">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/transport" element={<Transport />} />
</Routes>
</BrowserRouter>
// Navigation in component
import { useNavigate, useParams } from 'react-router-dom';
const navigate = useNavigate();
navigate('/transport?active_student=STU-001');
// Routes are driven by Refine resources + react-router-v6 integration
// Add to resources array in <Refine> component
resources={[
{
name: "notices",
list: "/notices",
create: "/notices/create",
edit: "/notices/edit/:id",
meta: { dataProviderName: "notices" },
},
]}
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { cn } from "@/lib/utils"; // clsx + tailwind-merge
// Variants with CVA
import { cva } from "class-variance-authority";
const buttonVariants = cva("base-classes", {
variants: { size: { sm: "...", lg: "..." } },
});
import { Button, TextInput, Select, Stack, Group, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
const [opened, { open, close }] = useDisclosure(false);
notifications.show({ title: "Saved", message: "Record updated", color: "green" });
// src/utils/frappeSocket.ts
import { subscribeToRoom, onRealtimeEvent, offRealtimeEvent, unsubscribeFromRoom } from '@/utils/frappeSocket';
// Subscribe to a room
subscribeToRoom(`transport_journey_${journeyId}`);
// Listen for events
const handler = (data: unknown) => { /* handle */ };
onRealtimeEvent('transport_gps_update', handler);
// Cleanup
offRealtimeEvent('transport_gps_update', handler);
unsubscribeFromRoom(`transport_journey_${journeyId}`);
# Parent App
cd apps/unity_parent_app/new_frontend
npm run dev # Dev server :8080
npm run build # Build → public/new_frontend/ + copy www/parent-app.html
# Walsh
cd apps/edu_quality/walsh
yarn dev # Dev server :8080
yarn build # TS compile → Vite build → public/walsh/ + copy www/walsh.html
# After any build
bench --site <site> clear-cache
bench build --app <app> # Only needed if static assets changed
import { Navigate } from 'react-router-dom';
import { isLoggedIn } from '@/utils/cookies';
const ProtectedRoute = ({ children }) =>
isLoggedIn() ? children : <Navigate to="/login" replace />;
// Wrap pages in ErrorBoundary from src/components/
import ErrorBoundary from '@/components/ErrorBoundary';
<ErrorBoundary fallback={<ErrorPage />}>
<MyPage />
</ErrorBoundary>
const formData = new FormData();
formData.append('file', file);
formData.append('doctype', 'Student');
formData.append('docname', studentId);
const res = await axiosInstance.post('/api/method/upload_file', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});