Craft Easy Admin — Generisk CRUD Admin-applikation¶
Version: 2.0 Datum: 2026-03-27 Relaterat: specification.md (sektion 16)
Innehållsförteckning¶
- Vision
- Arkitektur
- Teknisk stack
- Multi-session (flera API:er samtidigt)
- Admin-schema protokoll
- Applikationsstruktur
- Vyer och komponenter
- Relation-hantering
- Autentisering och rättigheter
- Realtidsupplevelse och ETag
- Tema och anpassning
- Plattformsspecifikt
- Testning
- Deployment
- Implementationsplan
1. Vision¶
Vad¶
En universell admin-applikation som bygger komplett CRUD-gränssnitt automatiskt genom att läsa ett API:s admin-schema. Fungerar som webb-app, iOS-app och Android-app från en enda kodbas. Stödjer flera API-instanser samtidigt med separata sessioner.
Nyckelfeatures¶
| Feature | Beskrivning |
|---|---|
| Schema-driven | Peka på API URL → komplett admin genereras |
| Multi-session | Spara favorit-API:er, inloggad i alla samtidigt, växla utan utloggning |
| Universal | Webb + iOS + Android från samma kodbas |
| Noll konfiguration | Fungerar dag 1 utan anpassning. AdminConfig ger extra polish. |
Designprinciper¶
| Princip | Regel |
|---|---|
| En kodbas, tre plattformar | React + Expo → webb, iOS, Android |
| Schema-driven | All UI genereras från /admin/schema. Ingen hårdkodad logik. |
| Multi-session first | Arkitekturen designas för flera samtidiga API-anslutningar från start |
| Mobil-först | Touch-optimerad, fungerar på 375px bredd |
| Offline-medveten | Schema cachas, listor cachas, formulär kräver uppkoppling |
2. Arkitektur¶
Övergripande¶
┌─────────────────────────────────────────────────────────────────┐
│ Craft Easy Admin (Expo) │
│ │
│ ┌─ Session Manager ──────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Airpark │ │ Hobby 1 │ │ Hobby 2 │ + Lägg │ │
│ │ │ api.air.. │ │ api.hob.. │ │ local:8000 │ till │ │
│ │ │ ● online │ │ ● online │ │ ○ offline │ │ │
│ │ └─────┬──────┘ └────────────┘ └────────────┘ │ │
│ └────────┼───────────────────────────────────────────────────┘ │
│ │ aktiv session │
│ ┌────────▼───────────────────────────────────────────────────┐ │
│ │ Schema Loader │ │
│ │ GET /admin/schema → cache → parse → generera UI │ │
│ └────────┬───────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────▼───────────────────────────────────────────────────┐ │
│ │ Dynamic Router (Expo Router) │ │
│ │ │ │
│ │ / → Session Switcher / Dashboard │ │
│ │ /[resource] → ListView │ │
│ │ /[resource]/create → CreateView │ │
│ │ /[resource]/[id] → DetailView │ │
│ │ /[resource]/[id]/edit → EditView │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ UI Layer ─────────────────────────────────────────────────┐ │
│ │ Webb: shadcn/ui (HTML) Native: React Native komponenter │ │
│ │ Delat: hooks, state, API-klient, schema-parser │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────────┘
│ HTTP (REST)
┌────────────────────┼──────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Airpark API │ │ Hobby 1 API │ │ Hobby 2 API │
│ (Cosmos) │ │ (Cosmos) │ │ (localhost) │
└──────────────┘ └──────────────┘ └──────────────┘
Dataflöde¶
1. Användare öppnar appen → ser sparade API-anslutningar
2. Väljer/lägger till API URL
3. App hämtar GET /admin/schema → cache:ar lokalt
4. Schema parsas → navigation, listor, formulär genereras
5. Användare navigerar → GET /{resource}?page=1&per_page=25
6. Användare redigerar → GET /{resource}/{id} (+ ETag)
7. Användare sparar → PATCH med If-Match: ETag
8. Byter API → appen laddar annat schema, annan session, annan token
3. Teknisk stack¶
Varför React + Expo?¶
| Krav | Expo ger |
|---|---|
| Webb + iOS + Android | En kodbas → alla tre plattformar |
| App Store / Google Play | Expo EAS Build → native binärer |
| OTA-uppdateringar | Expo Updates → uppdatera utan ny store-release |
| Multi-session state | Zustand/MMKV → snabb persistent lagring |
| Offline | Expo FileSystem + TanStack Query cache |
| Push-notiser (framtida) | Expo Notifications |
Stack¶
| Lager | Teknik | Motivering |
|---|---|---|
| Runtime | React 19 + Expo SDK 53 | Universal platform (webb + native) |
| Routing | Expo Router v4 | File-based, type-safe, deep linking, webb + native |
| UI (webb) | Tamagui eller gluestack-ui | Universal komponenter (renderar HTML på webb, native på mobil) |
| State | Zustand | Lättviktigt, TypeScript-first, fungerar överallt |
| Persistent state | MMKV (native) / localStorage (webb) | Sessioner, tokens, favoriter |
| Server state | TanStack Query v5 | Caching, revalidation, optimistic updates |
| Tabeller | TanStack Table v8 | Headless — renderar med vilken UI som helst |
| Formulär | React Hook Form + Zod | Best-in-class, genererar från schema |
| HTTP | ky (webb) / native fetch | Lättviktigt, interceptors, retry |
| Ikoner | @expo/vector-icons + Lucide | Fungerar native + webb |
| Datum | date-fns | Lättviktigt |
| CSS (webb) | NativeWind (Tailwind för RN) | Samma utility-klasser som Tailwind |
Varför inte shadcn/ui direkt?¶
shadcn/ui är HTML-baserat (div, input, etc.) — fungerar inte i React Native. Istället:
| Approach | Beskrivning |
|---|---|
| Tamagui | Universal komponenter: renderar <div> på webb, <View> på native. Tailwind-liknande styling. Bästa performance. |
| gluestack-ui v2 | Universal, byggt på NativeWind. Enklare API men inte lika snabbt som Tamagui. |
| Platform-specifika filer | DataTable.web.tsx + DataTable.native.tsx — Expo väljer rätt automatiskt |
Rekommendation: Tamagui — mest mogen universal UI-lösning för Expo.
Widget-mappning¶
| Admin-schema widget | Komponent | Webb | Native |
|---|---|---|---|
text |
TextInput |
<input> |
<TextInput> |
textarea |
TextArea |
<textarea> |
<TextInput multiline> |
number |
NumberInput |
<input type=number> |
<TextInput keyboardType=numeric> |
select |
Select |
<select> / dropdown |
Bottom sheet picker |
boolean |
Switch |
Toggle switch | Toggle switch |
date |
DatePicker |
HTML date input | Native date picker |
datetime |
DateTimePicker |
HTML datetime input | Native datetime picker |
email |
TextInput |
<input type=email> |
<TextInput keyboardType=email> |
relation |
RelationPicker |
Searchable dropdown | Bottom sheet + search |
media |
MediaUpload |
File input + drag | expo-image-picker |
json |
JsonEditor |
Monaco (webb only) | Read-only / TextInput |
markdown |
MarkdownEditor |
Rikt webb-editor | Enklare native editor |
4. Multi-session¶
4.1 Koncept¶
Användaren kan ha flera API-anslutningar sparade och vara inloggad i alla samtidigt. Växla mellan dem utan att logga ut.
4.2 Session-modell¶
// types/session.ts
interface ApiSession {
id: string // UUID
url: string // "https://api.airpark.com"
name: string // "Airpark" (från admin-schema title)
token: string | null // JWT — null om utloggad
tokenExpiry: number | null // Unix timestamp
refreshToken: string | null
schema: AdminSchema | null // Cachad schema
schemaUpdatedAt: number | null // När schema senast hämtades
theme: { // Från API:t
primaryColor: string
logoUrl?: string
} | null
lastUsed: number // Senaste användningstid (för sortering)
}
4.3 Session Store¶
// stores/sessions.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { mmkvStorage } from './mmkv' // MMKV för native, localStorage för webb
interface SessionStore {
sessions: ApiSession[]
activeSessionId: string | null
// Actions
addSession: (url: string) => Promise<void>
removeSession: (id: string) => void
switchSession: (id: string) => void
updateToken: (id: string, token: string, expiry: number) => void
updateSchema: (id: string, schema: AdminSchema) => void
// Computed
activeSession: () => ApiSession | null
activeSessions: () => ApiSession[] // Alla med giltig token
}
export const useSessionStore = create<SessionStore>()(
persist(
(set, get) => ({
sessions: [],
activeSessionId: null,
addSession: async (url: string) => {
// 1. Hämta schema (utan auth — för att få title + theme)
const schema = await fetch(`${url}/admin/schema`).then(r => r.json())
const session: ApiSession = {
id: crypto.randomUUID(),
url,
name: schema.title,
token: null,
tokenExpiry: null,
refreshToken: null,
schema,
schemaUpdatedAt: Date.now(),
theme: schema.theme || null,
lastUsed: Date.now(),
}
set(state => ({
sessions: [...state.sessions, session],
activeSessionId: session.id,
}))
},
switchSession: (id: string) => {
set(state => ({
activeSessionId: id,
sessions: state.sessions.map(s =>
s.id === id ? { ...s, lastUsed: Date.now() } : s
),
}))
},
activeSession: () => {
const state = get()
return state.sessions.find(s => s.id === state.activeSessionId) || null
},
activeSessions: () => {
return get().sessions.filter(s => s.token !== null)
},
// ... övriga actions
}),
{
name: 'craft-easy-admin-sessions',
storage: createJSONStorage(() => mmkvStorage),
}
)
)
4.4 API-klient med session-kontext¶
// lib/api-client.ts
import { useSessionStore } from '../stores/sessions'
export function createApiClient(sessionId?: string) {
const getSession = () => {
const store = useSessionStore.getState()
return sessionId
? store.sessions.find(s => s.id === sessionId)
: store.activeSession()
}
return {
fetch: async (path: string, options: RequestInit = {}) => {
const session = getSession()
if (!session) throw new Error('No active session')
const headers = new Headers(options.headers)
// Auth
if (session.token) {
headers.set('X-API-Key', session.token)
}
const response = await fetch(`${session.url}${path}`, {
...options,
headers,
})
// Auto-refresh om token snart går ut
if (session.tokenExpiry && session.tokenExpiry - Date.now() < 5 * 60 * 1000) {
refreshToken(session)
}
return response
}
}
}
4.5 Session Switcher UI¶
// components/SessionSwitcher.tsx
export function SessionSwitcher() {
const { sessions, activeSessionId, switchSession } = useSessionStore()
return (
<ScrollView horizontal>
{sessions.map(session => (
<Pressable
key={session.id}
onPress={() => switchSession(session.id)}
style={[
styles.sessionCard,
session.id === activeSessionId && styles.activeCard
]}
>
{session.theme?.logoUrl && (
<Image source={{ uri: session.theme.logoUrl }} style={styles.logo} />
)}
<Text style={styles.name}>{session.name}</Text>
<View style={[
styles.statusDot,
{ backgroundColor: session.token ? '#22c55e' : '#9ca3af' }
]} />
</Pressable>
))}
<Pressable onPress={showAddDialog} style={styles.addCard}>
<Ionicons name="add" size={24} />
</Pressable>
</ScrollView>
)
}
5. Admin-schema protokoll¶
Endpoint¶
Response¶
interface AdminSchema {
version: string // "1.0"
title: string // "Airpark API"
theme?: ThemeConfig
auth: AuthConfig
navigation: NavigationGroup[]
resources: ResourceSchema[]
}
interface ThemeConfig {
primary_color: string // "#2563eb"
logo_url?: string
favicon_url?: string
}
interface AuthConfig {
login_endpoint: string // "/auth/login/api-code"
refresh_endpoint: string // "/auth/refresh"
token_header: string // "X-API-Key"
}
interface NavigationGroup {
label: string
icon?: string
resources: string[]
}
interface ResourceSchema {
endpoint: string // "/parking-zones"
name: string // "parking-zones"
label: string // "Parking Zone"
label_plural: string // "Parking Zones"
icon?: string
description?: string
methods: string[] // ["GET", "POST", "PATCH", "DELETE"]
list: ListConfig
form: FormConfig
fields: Record<string, FieldSchema>
features: ResourceFeatures
}
interface ListConfig {
fields: string[]
search_fields: string[]
filter_fields: string[]
sort_default: string
sort_fields: string[]
page_size: number
}
interface FormConfig {
groups: FormGroup[]
}
interface FormGroup {
label: string
fields: string[]
collapsible?: boolean
collapsed?: boolean
}
interface FieldSchema {
type: string // "string" | "integer" | "number" | "boolean" |
// "datetime" | "objectid" | "array" | "object"
required: boolean
readonly: boolean
widget: string // "text" | "textarea" | "number" | "select" |
// "boolean" | "date" | "datetime" | "email" |
// "relation" | "media" | "json" | "markdown" |
// "color" | "url" | "hidden"
label?: string
placeholder?: string
help_text?: string
min?: number
max?: number
step?: number
min_length?: number
max_length?: number
pattern?: string
options?: SelectOption[]
relation?: RelationConfig
accept?: string
max_size_mb?: number
}
interface SelectOption {
value: string
label: string
color?: string
icon?: string
}
interface RelationConfig {
endpoint: string
label_field: string
search_field: string
value_field: string
filters?: Record<string, string>
}
interface ResourceFeatures {
create: boolean
edit: boolean
delete: boolean
export: boolean
bulk_delete: boolean
duplicate: boolean
}
Fallback: Auto-schema från OpenAPI¶
Om en resurs saknar AdminConfig genereras schemat automatiskt:
| OpenAPI | Widget |
|---|---|
type: string |
text |
type: string, format: email |
email |
type: string, format: date-time |
datetime |
type: integer / number |
number |
type: boolean |
boolean |
type: string, enum: [...] |
select |
$ref ObjectId |
relation (gissning) |
6. Applikationsstruktur¶
craft-easy-admin/
├── app.json # Expo-konfiguration
├── package.json
├── tsconfig.json
├── tailwind.config.ts # NativeWind
├── tamagui.config.ts # Tamagui theme
│
├── app/ # Expo Router (file-based routing)
│ ├── _layout.tsx # Root layout (providers, session)
│ ├── index.tsx # Start: Session switcher / Dashboard
│ ├── login.tsx # Login för aktiv session
│ ├── add-api.tsx # Lägg till ny API-anslutning
│ │
│ └── (admin)/ # Admin-grupp (kräver aktiv session)
│ ├── _layout.tsx # Admin layout (sidebar/tabs + content)
│ ├── index.tsx # Dashboard
│ └── [resource]/ # Dynamisk resurs-routing
│ ├── index.tsx # ListView
│ ├── create.tsx # CreateView
│ └── [id]/
│ ├── index.tsx # DetailView
│ └── edit.tsx # EditView
│
├── components/
│ ├── sessions/
│ │ ├── SessionSwitcher.tsx # Horisontell lista med API-sessions
│ │ ├── SessionCard.tsx # Kort för en session (logo, status)
│ │ └── AddApiDialog.tsx # Modal för att lägga till API URL
│ │
│ ├── admin/
│ │ ├── Sidebar.tsx # Navigation — webb: sidebar, native: drawer
│ │ ├── Sidebar.web.tsx # Webb-specifik sidebar (valfritt)
│ │ ├── BottomTabs.native.tsx # Native-specifik bottom navigation
│ │ ├── Header.tsx # Topbar med session-switcher
│ │ └── Dashboard.tsx # Räknare, senaste ändringar
│ │
│ ├── list/
│ │ ├── DataTable.tsx # Tabell (webb: HTML table, native: FlatList)
│ │ ├── DataTable.web.tsx # Webb: TanStack Table → HTML
│ │ ├── DataTable.native.tsx # Native: FlatList med kort-layout
│ │ ├── FilterBar.tsx
│ │ ├── SearchInput.tsx
│ │ ├── CellRenderer.tsx
│ │ ├── Pagination.tsx
│ │ └── ListItem.native.tsx # Native: listelement som kort
│ │
│ ├── form/
│ │ ├── FormBuilder.tsx # Schema → formulär
│ │ ├── FieldRenderer.tsx # Väljer rätt widget
│ │ ├── FieldGroup.tsx
│ │ └── FormActions.tsx
│ │
│ └── widgets/
│ ├── TextInput.tsx
│ ├── TextArea.tsx
│ ├── NumberInput.tsx
│ ├── SelectInput.tsx # Webb: dropdown, native: bottom sheet
│ ├── BooleanSwitch.tsx
│ ├── DatePicker.tsx # Webb: HTML input, native: expo datepicker
│ ├── DateTimePicker.tsx
│ ├── EmailInput.tsx
│ ├── UrlInput.tsx
│ ├── ColorPicker.tsx
│ ├── RelationPicker.tsx # Searchable (webb: dropdown, native: modal)
│ ├── MediaUpload.tsx # Webb: drag+drop, native: expo-image-picker
│ ├── JsonEditor.tsx # Webb: Monaco, native: readonly/textarea
│ └── MarkdownEditor.tsx
│
├── hooks/
│ ├── useAdminSchema.ts # Hämta + cache:a schema för aktiv session
│ ├── useResource.ts # CRUD-operationer
│ ├── useResourceList.ts # Lista med TanStack Query
│ ├── useResourceForm.ts # Formulär + ETag
│ ├── useRelation.ts # Hämta relaterade resurser (debounced search)
│ └── useETag.ts # ETag-hantering
│
├── stores/
│ ├── sessions.ts # Multi-session state (Zustand + MMKV)
│ └── preferences.ts # Tema, språk
│
├── lib/
│ ├── api-client.ts # Session-aware fetch wrapper
│ ├── schema-parser.ts # AdminSchema → UI-konfiguration
│ ├── form-validator.ts # FieldSchema → Zod schema
│ └── platform.ts # Platform detection helpers
│
└── types/
├── schema.ts # AdminSchema, ResourceSchema, etc.
└── session.ts # ApiSession
Plattformsspecifika filer¶
Expo väljer automatiskt rätt fil baserat på plattform:
DataTable.tsx → Default (används om ingen specifik finns)
DataTable.web.tsx → Används på webb
DataTable.native.tsx → Används på iOS + Android
DataTable.ios.tsx → Används bara på iOS
DataTable.android.tsx → Används bara på Android
Strategi: Dela så mycket som möjligt. Bara komponenter som renderar fundamentalt annorlunda (tabell vs lista, dropdown vs bottom sheet) behöver plattformsspecifika filer.
7. Vyer och komponenter¶
7.1 Root Layout¶
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { TamaguiProvider } from 'tamagui'
import { Stack } from 'expo-router'
const queryClient = new QueryClient()
export default function RootLayout() {
return (
<TamaguiProvider>
<QueryClientProvider client={queryClient}>
<Stack>
<Stack.Screen name="index" options={{ title: 'Craft Easy Admin' }} />
<Stack.Screen name="login" options={{ presentation: 'modal' }} />
<Stack.Screen name="add-api" options={{ presentation: 'modal' }} />
<Stack.Screen name="(admin)" options={{ headerShown: false }} />
</Stack>
</QueryClientProvider>
</TamaguiProvider>
)
}
7.2 Session Switcher (startsida)¶
// app/index.tsx
import { useSessionStore } from '../stores/sessions'
export default function HomeScreen() {
const { sessions, activeSessionId, switchSession } = useSessionStore()
if (sessions.length === 0) {
return <EmptyState onAdd={() => router.push('/add-api')} />
}
return (
<View style={{ flex: 1 }}>
<Text style={styles.heading}>Your APIs</Text>
{sessions.map(session => (
<SessionCard
key={session.id}
session={session}
isActive={session.id === activeSessionId}
onPress={() => {
switchSession(session.id)
if (session.token) {
router.push('/(admin)')
} else {
router.push('/login')
}
}}
onLongPress={() => showSessionOptions(session)}
/>
))}
<Button onPress={() => router.push('/add-api')}>
Add API
</Button>
</View>
)
}
7.3 Admin Layout¶
// app/(admin)/_layout.tsx
import { useAdminSchema } from '../../hooks/useAdminSchema'
import { Platform } from 'react-native'
export default function AdminLayout() {
const { schema, isLoading } = useAdminSchema()
if (isLoading) return <LoadingScreen />
// Webb: sidebar layout
// Native: drawer eller bottom tabs
if (Platform.OS === 'web') {
return (
<View style={{ flexDirection: 'row', flex: 1 }}>
<Sidebar schema={schema} />
<View style={{ flex: 1 }}>
<Header />
<Slot />
</View>
</View>
)
}
// Native: Drawer navigation
return (
<Drawer>
<Drawer.Screen name="index" options={{ title: 'Dashboard' }} />
{schema?.resources.map(resource => (
<Drawer.Screen
key={resource.name}
name={`[resource]`}
options={{ title: resource.label_plural }}
initialParams={{ resource: resource.name }}
/>
))}
</Drawer>
)
}
7.4 ListView — plattformsanpassad¶
// components/list/DataTable.web.tsx — Webb-version
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'
export function DataTable({ schema, data, onSort, onPageChange }) {
const columns = schema.list.fields.map(field => ({
accessorKey: field,
header: schema.fields[field].label || field,
cell: ({ getValue }) => (
<CellRenderer value={getValue()} fieldSchema={schema.fields[field]} />
),
}))
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table className="w-full">
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} onClick={() => onSort(header.id)}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} onClick={() => router.push(`/${schema.name}/${row.original._id}`)}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
// components/list/DataTable.native.tsx — Native-version (kortlayout)
import { FlatList } from 'react-native'
export function DataTable({ schema, data, onEndReached }) {
return (
<FlatList
data={data}
keyExtractor={item => item._id}
onEndReached={onEndReached}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/${schema.name}/${item._id}`)}
style={styles.card}
>
<Text style={styles.title}>{item[schema.list.fields[0]]}</Text>
{schema.list.fields.slice(1, 4).map(field => (
<View key={field} style={styles.row}>
<Text style={styles.label}>{schema.fields[field].label || field}</Text>
<CellRenderer value={item[field]} fieldSchema={schema.fields[field]} />
</View>
))}
</Pressable>
)}
/>
)
}
7.5 FormBuilder (universal)¶
// components/form/FormBuilder.tsx
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { generateZodSchema } from '../../lib/form-validator'
export function FormBuilder({ schema, data, mode, onSubmit, onCancel }) {
const validationSchema = generateZodSchema(schema.fields)
const { control, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(validationSchema),
defaultValues: data || {},
})
const groups = schema.form?.groups?.length
? schema.form.groups
: [{ label: 'General', fields: Object.keys(schema.fields).filter(f =>
!schema.fields[f].readonly && !schema.fields[f].hidden
)}]
return (
<ScrollView>
{groups.map(group => (
<FieldGroup key={group.label} label={group.label}>
{group.fields.map(fieldName => (
<Controller
key={fieldName}
name={fieldName}
control={control}
render={({ field }) => (
<FieldRenderer
name={fieldName}
schema={schema.fields[fieldName]}
value={field.value}
onChange={field.onChange}
error={errors[fieldName]?.message}
/>
)}
/>
))}
</FieldGroup>
))}
<FormActions
mode={mode}
onSubmit={handleSubmit(onSubmit)}
onCancel={onCancel}
/>
</ScrollView>
)
}
7.6 FieldRenderer (universal med plattformsspecifika widgets)¶
// components/form/FieldRenderer.tsx
const WIDGET_MAP: Record<string, React.ComponentType<WidgetProps>> = {
text: TextInput,
textarea: TextArea,
number: NumberInput,
select: SelectInput, // .web.tsx → dropdown, .native.tsx → bottom sheet
boolean: BooleanSwitch,
date: DatePicker, // .web.tsx → HTML, .native.tsx → native picker
datetime: DateTimePicker,
email: EmailInput,
url: UrlInput,
color: ColorPicker,
relation: RelationPicker, // .web.tsx → searchable dropdown, .native.tsx → modal
media: MediaUpload, // .web.tsx → drag+drop, .native.tsx → image picker
json: JsonEditor,
markdown: MarkdownEditor,
}
export function FieldRenderer({ name, schema, value, onChange, error }: WidgetProps) {
const Widget = WIDGET_MAP[schema.widget] || TextInput
return (
<View>
<Label>{schema.label || name}</Label>
<Widget
value={value}
onChange={onChange}
schema={schema}
/>
{schema.help_text && <HelpText>{schema.help_text}</HelpText>}
{error && <ErrorText>{error}</ErrorText>}
</View>
)
}
8. Relation-hantering¶
// components/widgets/RelationPicker.tsx
import { useRelation } from '../../hooks/useRelation'
export function RelationPicker({ schema, value, onChange }) {
const { relation } = schema
const { items, search, setSearch, isLoading, selectedLabel } = useRelation(relation, value)
// Plattformsspecifik rendering hanteras via .web.tsx / .native.tsx
// Detta är den delade logiken
return (
<SearchableSelect
value={value}
label={selectedLabel}
options={items}
loading={isLoading}
onSearch={setSearch}
onChange={onChange}
placeholder={`Search ${relation.label_field}...`}
/>
)
}
// hooks/useRelation.ts
import { useQuery } from '@tanstack/react-query'
import { useApiClient } from './useApiClient'
import { useDebouncedValue } from './useDebouncedValue'
export function useRelation(config: RelationConfig, currentValue?: string) {
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
const api = useApiClient()
// Hämta options
const { data: items, isLoading } = useQuery({
queryKey: ['relation', config.endpoint, debouncedSearch],
queryFn: () => api.fetch(`${config.endpoint}?${config.search_field}__regex=${debouncedSearch}&per_page=20`),
select: (data) => data.items.map(item => ({
value: item[config.value_field || '_id'],
label: item[config.label_field || 'name'],
})),
})
// Hämta label för nuvarande värde
const { data: selectedLabel } = useQuery({
queryKey: ['relation-label', config.endpoint, currentValue],
queryFn: () => api.fetch(`${config.endpoint}/${currentValue}`),
select: (data) => data[config.label_field || 'name'],
enabled: !!currentValue,
})
return { items: items || [], search, setSearch, isLoading, selectedLabel }
}
9. Autentisering och rättigheter¶
9.1 Multi-session auth¶
Varje session har sin egen token. Login sker per session:
// app/login.tsx
export default function LoginScreen() {
const { activeSession, updateToken } = useSessionStore()
const [apiCode, setApiCode] = useState('')
const login = async () => {
const response = await fetch(`${activeSession.url}/auth/login/api-code`, {
method: 'POST',
headers: { 'X-API-Key': apiCode },
})
const { token, expires } = await response.json()
updateToken(activeSession.id, token, new Date(expires).getTime())
router.replace('/(admin)')
}
return (
<View>
<Text>Login to {activeSession?.name}</Text>
<TextInput
value={apiCode}
onChangeText={setApiCode}
placeholder="API Key"
secureTextEntry
/>
<Button onPress={login}>Login</Button>
</View>
)
}
9.2 Auto-refresh per session¶
// lib/token-refresh.ts
export function setupTokenRefresh() {
setInterval(() => {
const { sessions, updateToken } = useSessionStore.getState()
sessions.forEach(async (session) => {
if (!session.token || !session.tokenExpiry) return
// Refresh 5 minuter innan expiry
const timeLeft = session.tokenExpiry - Date.now()
if (timeLeft < 5 * 60 * 1000 && timeLeft > 0) {
const response = await fetch(`${session.url}/auth/refresh`, {
method: 'POST',
headers: { 'X-API-Key': session.token },
})
const { token, expires } = await response.json()
updateToken(session.id, token, new Date(expires).getTime())
}
})
}, 60_000) // Kolla varje minut
}
9.3 Feature-baserad UI¶
const canCreate = schema.methods.includes('POST') && schema.features.create
const canEdit = schema.methods.includes('PATCH') && schema.features.edit
const canDelete = schema.methods.includes('DELETE') && schema.features.delete
// Dölj knappar baserat på rättigheter
{canCreate && <Button onPress={() => router.push(`/${resource}/create`)}>New</Button>}
10. Realtidsupplevelse och ETag¶
10.1 ETag-hook¶
// hooks/useETag.ts
export function useETag() {
const etags = useRef(new Map<string, string>())
const fetchWithETag = async (url: string) => {
const response = await api.fetch(url)
const etag = response.headers.get('ETag')
if (etag) etags.current.set(url, etag)
return response
}
const patchWithETag = async (url: string, data: any) => {
const etag = etags.current.get(url)
if (!etag) throw new Error('No ETag — fetch first')
return api.fetch(url, {
method: 'PATCH',
body: JSON.stringify(data),
headers: { 'If-Match': etag },
})
}
return { fetchWithETag, patchWithETag }
}
10.2 Optimistic updates via TanStack Query¶
// hooks/useResourceForm.ts
const mutation = useMutation({
mutationFn: (data) => patchWithETag(`/${resource}/${id}`, data),
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: [resource, id] })
const previous = queryClient.getQueryData([resource, id])
queryClient.setQueryData([resource, id], { ...previous, ...newData })
return { previous }
},
onError: (err, newData, context) => {
queryClient.setQueryData([resource, id], context.previous)
if (err.status === 412) {
showConflictDialog()
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [resource, id] })
},
})
11. Tema och anpassning¶
Per-session tema¶
Varje API kan returnera tema i admin-schemat. Appen applicerar temat för aktiv session:
// app/_layout.tsx
const activeSession = useSessionStore(s => s.activeSession())
const theme = useMemo(() => ({
...defaultTheme,
primaryColor: activeSession?.theme?.primaryColor || '#2563eb',
}), [activeSession])
return (
<TamaguiProvider theme={theme}>
{children}
</TamaguiProvider>
)
Dark mode¶
import { useColorScheme } from 'react-native'
const colorScheme = useColorScheme() // 'light' | 'dark'
Tamagui hanterar dark/light automatiskt baserat på system-inställning.
12. Plattformsspecifikt¶
Vad som delas vs separeras¶
| Komponent | Webb | Native | Delad kod |
|---|---|---|---|
| Hooks (all logik) | — | — | 100% |
| Stores (state) | — | — | 100% |
| Schema-parser | — | — | 100% |
| API-klient | — | — | 100% |
| Formulär-logik | — | — | 100% |
| Validering (Zod) | — | — | 100% |
| DataTable | HTML table | FlatList kort | ~30% |
| SelectInput | <select> / dropdown |
Bottom sheet | ~20% |
| DatePicker | HTML date input | Native picker | ~10% |
| MediaUpload | Drag + drop | expo-image-picker | ~10% |
| Sidebar | Vänster sidebar | Drawer | ~20% |
| JsonEditor | Monaco | Readonly/textarea | ~0% |
~85% av koden delas. Bara rendering-lagret skiljer sig.
Navigation¶
| Plattform | Mönster |
|---|---|
| Webb (desktop) | Sidebar (vänster) + content |
| Webb (mobil) | Hamburger → drawer |
| Native | Drawer + stack navigation |
13. Testning¶
Stack¶
| Verktyg | Syfte |
|---|---|
| Jest + @testing-library/react-native | Unit + component tests |
| MSW | Mock API responses |
| Maestro | E2E native tests (iOS/Android) |
| Playwright | E2E webb tests |
Teststrategi¶
Delad kod (hooks, stores, lib) → Jest unit tests (90% coverage)
Webb-komponenter → Playwright E2E
Native-komponenter → Maestro E2E
14. Deployment¶
Webb¶
| Miljö | URL |
|---|---|
| Dev | localhost:8081 |
| Test | admin-test.airpark.com |
| Prod | admin.airpark.com |
iOS + Android¶
# Bygg med EAS (Expo Application Services)
eas build --platform ios
eas build --platform android
# Publicera till stores
eas submit --platform ios
eas submit --platform android
OTA-uppdateringar (utan ny store-release)¶
eas update --branch production --message "Fix session switching bug"
# → Alla användare får uppdateringen nästa gång appen startar
CI/CD¶
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npx expo export:web
- uses: Azure/static-web-apps-deploy@v1
with:
app_location: 'dist'
native:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: eas build --platform all --non-interactive
- run: eas submit --platform all --non-interactive
15. Implementationsplan¶
Fas 1: Fundament + Multi-session (3 veckor)¶
| Vecka | Leverans |
|---|---|
| 1 | Expo-projekt, Expo Router, Zustand session store, session switcher UI |
| 2 | Schema loader, admin layout (sidebar webb, drawer native), dynamisk routing |
| 3 | API-klient med session-kontext, login per session, token refresh |
Resultat: App som kan lägga till API:er, logga in, växla session.
Fas 2: CRUD (3 veckor)¶
| Vecka | Leverans |
|---|---|
| 4 | ListView: DataTable (webb + native), sök, pagination, sortering |
| 5 | FormBuilder, FieldRenderer, alla grundwidgets (text, number, select, boolean, date) |
| 6 | CreateView, EditView med ETag, DeleteView med bekräftelse |
Resultat: Full CRUD på alla resurser, webb + native.
Fas 3: Rikt gränssnitt (2 veckor)¶
| Vecka | Leverans |
|---|---|
| 7 | RelationPicker, FilterBar, CellRenderer (badges, färger), optimistic updates |
| 8 | MediaUpload, dark mode, per-session tema, konflikthantering (412) |
Resultat: Polerad admin med alla widgets och bra UX.
Fas 4: Distribution (2 veckor)¶
| Vecka | Leverans |
|---|---|
| 9 | Webb-deploy (Azure SWA), EAS Build (iOS + Android), CI/CD pipeline |
| 10 | Testning (Jest + Playwright + Maestro), App Store + Google Play submission |
Resultat: Admin tillgänglig som webb, iOS-app och Android-app.
Totalt: 10 veckor¶
Sammanfattning¶
| Beslut | Val | Motivering |
|---|---|---|
| Ramverk | React 19 + Expo SDK 53 | En kodbas → webb + iOS + Android |
| Routing | Expo Router v4 | File-based, universal, deep linking |
| UI | Tamagui | Universal komponenter (HTML + native) |
| State | Zustand + MMKV | Snabb, persistent, multi-session |
| Server state | TanStack Query v5 | Caching, optimistic updates |
| Formulär | React Hook Form + Zod | Schema-driven, best-in-class |
| Tabeller | TanStack Table v8 | Headless, samma logik webb + native |
| Multi-session | Zustand store med session-per-API | Flera API:er, inloggad överallt |
| Hosting webb | Azure Static Web Apps | Kredit-täckt |
| App stores | Expo EAS Build + Submit | iOS + Android från CI/CD |
| OTA | Expo Updates | Uppdatera utan ny store-release |
| Tid | ~10 veckor | Fundament (3v) → CRUD (3v) → Polish (2v) → Ship (2v) |