📖 See Also: Documentation Index | Architecture | Field Wrapper System | Developer Guide
The OpenFields Admin System is a React/TypeScript SPA that provides an intuitive interface for managing custom fields. It communicates with the WordPress backend via REST API and uses Zustand for state management.
- File:
admin/src/main.tsx - Mounts: React app into
#openfields-admindiv - Bootstrap: Loads user data via
window.openfieldsAdminglobal - Build Tool: Vite
- File:
admin/src/App.tsx - Responsibility: Main navigation & route handling
- Pages:
FieldsetList- Browse all fieldsetsFieldsetEditor- Create/edit fieldsetTools- Utility functions
Manages all fieldset and field data plus pending changes.
interface ExtendedFieldsetStore {
// Data
fieldsets: Fieldset[];
currentFieldset: Fieldset | null;
fields: Field[];
// UI state
isLoading: boolean;
error: string | null;
unsavedChanges: boolean;
// Pending changes (client-side)
pendingFieldChanges: Map<string, Partial<Field>>;
pendingFieldAdditions: Field[];
pendingFieldDeletions: string[];
// Actions
fetchFieldsets(): Promise<void>;
fetchFieldset(id: number): Promise<void>;
createFieldset(data: Partial<Fieldset>): Promise<Fieldset>;
updateFieldset(id: number, data: Partial<Fieldset>): Promise<void>;
deleteFieldset(id: number): Promise<void>;
duplicateFieldset(id: number): Promise<Fieldset>;
// Field operations
fetchFields(fieldsetId: number): Promise<void>;
addFieldLocal(field: Partial<Field>): void;
updateFieldLocal(id: string, data: Partial<Field>): void;
deleteFieldLocal(id: string): void;
reorderFieldsLocal(fields: Field[]): void;
saveAllChanges(): Promise<void>;
}Key Pattern: Local staging of changes before saving. This allows users to:
- Add/edit multiple fields
- Reorder fields
- Delete fields
- Then click Save to send all changes to server atomically
Manages toast notifications and modal states.
interface UIStore {
showToast(
type: 'success' | 'error' | 'info',
message: string,
duration?: number
): void;
// Modal state...
}Provides type-safe REST client with auto-transforms for field data.
Frontend ↔ Backend data format differences:
// Backend returns fields with settings split into top-level properties:
// { placeholder, default_value, instructions, conditional_logic, wrapper_config, field_config }
// Frontend expects unified settings object:
// { settings: { placeholder, default_value, instructions, ... } }
transformFieldFromAPI() // Converts backend → frontend
transformFieldToAPI() // Converts frontend → backendconst fieldsetApi = {
getAll(),
get(id),
create(data),
update(id, data),
delete(id),
duplicate(id),
export(id),
import(data),
};
const fieldApi = {
getByFieldset(fieldsetId),
create(fieldsetId, data),
update(id, data),
delete(id),
};
const locationApi = {
getLocationTypes(),
};Purpose: Browse, create, and manage fieldsets
Features:
- List all fieldsets with status badge
- Create new fieldset button
- Edit fieldset link
- Delete fieldset
- Duplicate fieldset
- View field count
Purpose: Edit fieldset name, description, location rules, and manage fields
Layout:
┌─ Sticky Header ──────────────────────────┐
│ Title Input [Save Changes] Btn │
└──────────────────────────────────────────┘
│
├─ FieldsSection
│ - Add field button
│ - Drag-to-reorder list
│ - Field edit/delete actions
│ - Shows: Label, Type, Status
│
├─ SettingsSection
│ - Active/Inactive toggle
│ - Slug/Key input
│ - Description textarea
│
└─ LocationsSection
- Location rule builder
- Add/remove rules
- AND/OR logic visualization
Responsibility: Display and manage field list
Features:
- Add new field dropdown
- Drag-to-reorder (using SortableJS/dnd-kit)
- Field card showing type & label
- Edit field button → opens TypeSpecificSettings modal
- Delete field button → stages deletion locally
- Preview field configuration
State Flow:
- User adds field →
addFieldLocal()→ shown in list immediately - User edits field →
updateFieldLocal()→ updates in place - User reorders →
reorderFieldsLocal()→ updates menu_order - User clicks Save →
saveAllChanges()→ sends to API
Responsibility: Visual builder for location rules
Rule Structure:
- Multiple groups (OR logic between groups)
- Each group has multiple rules (AND logic between rules)
- Each rule: Type selector → Operator selector → Value selector
Example:
GROUP 1:
Rule: Post Type == Page AND
Rule: Page Template == default
[OR logic separator]
GROUP 2:
Rule: Post Type == Post
This means: Show on (page AND default template) OR (post)
Frontend Format (sent to backend):
[
{
id: '1',
rules: [
{ type: 'post_type', operator: '==', value: 'page' },
{ type: 'page_template', operator: '==', value: 'default' }
]
},
{
id: '2',
rules: [
{ type: 'post_type', operator: '==', value: 'post' }
]
}
]Database Format (stored in wp_openfields_locations):
fieldset_id | param | operator | value | group_id
1 | post_type | == | page | 0
1 | page_template | == | default | 0
1 | post_type | == | post | 1
Responsibility: Fieldset-level configuration
Fields:
- Title (header input, synced with fieldset)
- Slug/Field Key (auto-generated, editable)
- Description (rich text optional)
- Status (Active/Inactive toggle)
- Position (normal/side selector)
- Priority (high/low/default selector)
Responsibility: Render field-type-specific settings UI
Pattern: Each field type has a settings component:
TextFieldSettings.tsx- min length, max length, patternNumberFieldSettings.tsx- min value, max value, stepSelectFieldSettings.tsx- choices, multi-select toggleTextareaFieldSettings.tsx- rows, rich text toggleSwitchFieldSettings.tsx- on/off labels
Registry Pattern:
// fields/index.ts
const fieldSettingsRegistry = {
text: TextFieldSettings,
number: NumberFieldSettings,
select: SelectFieldSettings,
textarea: TextareaFieldSettings,
// ...
};
// Usage in TypeSpecificSettings:
const SettingsComponent = fieldSettingsRegistry[fieldType];
<SettingsComponent
value={settings}
onChange={handleSettingChange}
/>Defines all available field types with metadata:
export const FIELD_TYPES = [
{
id: 'text',
label: 'Text',
icon: 'type',
description: 'Single line text input',
settings: {
placeholder: { type: 'text', default: '' },
default_value: { type: 'text', default: '' },
required: { type: 'boolean', default: false },
instructions: { type: 'text', default: '' },
min_length: { type: 'number', default: 0 },
max_length: { type: 'number', default: null },
pattern: { type: 'text', default: '' },
}
},
// ... more field types
];Each field type has a settings component following this pattern:
interface FieldSettingsProps {
field: Field;
onUpdate: (field: Partial<Field>) => void;
}
export function TextFieldSettings({ field, onUpdate }: FieldSettingsProps) {
const settings = field.settings || {};
const handleChange = (key: string, value: any) => {
onUpdate({
...field,
settings: {
...settings,
[key]: value,
}
});
};
return (
<div className="field-settings">
<InputField
label="Placeholder"
value={settings.placeholder || ''}
onChange={(val) => handleChange('placeholder', val)}
/>
<InputField
label="Max Length"
type="number"
value={settings.max_length || ''}
onChange={(val) => handleChange('max_length', val)}
/>
{/* ... more settings */}
</div>
);
}When user visits post/page edit screen:
- WordPress registers meta boxes via PHP
add_meta_boxesaction - Gutenberg/Block Editor renders meta boxes below content
- JavaScript (in
meta-box.js) initializes field interactions:- Color picker
- Media uploader for image fields
- Conditional logic evaluation
- Value persistence
WordPress Post/Page Edit
↓
add_meta_boxes hook fires
↓
OpenFields_Meta_Box::register_meta_boxes()
↓
Location matching: Does fieldset location match current post?
↓
YES → add_meta_box() called
↓
render_meta_box() outputs HTML
↓
Gutenberg shows in sidebar below content
↓
save_post hook → OpenFields_Meta_Box::save_post()
↓
Field values saved to postmeta with of_ prefix
TIER 1: User edits field in UI
↓ (immediate)
TIER 2: Local Zustand store updates
↓ (user clicks field setting)
TIER 3: `updateFieldLocal()` staged in pendingFieldChanges Map
↓ (user clicks Save)
API call: POST to /fieldsets/{id}/fields with all changesBenefits:
- Users can batch multiple field changes
- Undo possible by not clicking Save
- Single HTTP request for multiple operations
- Atomic save (all-or-nothing)
// In FieldsetEditor
const unsavedChanges = useFieldsetStore((state) => state.unsavedChanges);
// Triggers when:
// 1. Field added locally
// 2. Field updated locally
// 3. Field deleted locally
// 4. Field reordered
// 5. Fieldset title/description changed
// 6. Location rules changed
// Disables Save button when false
// Could add browser warning on page leave- Framework: Tailwind CSS
- Component Library: Shadcn UI (built on Radix)
- Global Styles:
styles/main.css - Inline Styles: Minimal, used for dynamic values
try {
const fieldset = await fieldsetApi.update(id, data);
} catch (error) {
showToast('error', error.message || 'Update failed');
setError(error);
}- Frontend: React hook form validates before submission
- Backend: REST API validates and returns 400 with message
- Display: Toast notification with error message
- Auto-retry for GET requests
- Manual retry button for failed mutations
- Connection status indicator (optional enhancement)
- Selective Re-renders: Zustand selectors prevent unnecessary component updates
- Lazy Load: Pages loaded on-demand
- Memoization:
useMemo/useCallbackfor expensive computations - Image Optimization: Field type icons lazy-loaded
- Code Splitting: Each page bundle separate
- Create fieldset
- Add fields of various types
- Configure location rules
- Set field-specific settings
- Save everything
- Go to post/page edit → verify meta boxes appear
- Fill in field values
- Save post
- Reload → verify values persist
- Field registry lookup
- Location rule matching
- API client transforms
- Store actions
Current:
- Semantic HTML
- ARIA labels on inputs
- Keyboard navigation in dropdowns
Future:
- Screen reader testing
- High contrast mode
- Keyboard-only workflow validation
- Focus indicators
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- IE11 - Not supported (uses ES2020+, optional chaining)
cd admin
# Terminal 1: Watch mode
npm run dev
# Terminal 2: Build admin changes
npm run build
# The built files go to plugin/assets/admin/- React DevTools: Chrome extension
- Redux DevTools: Alternative (not currently used)
- Zustand Middleware: Log store mutations:
const store = create( devtools( (set) => { /* ... */ } ) );
- Network Tab: Monitor REST API calls
- Create new component in appropriate directory
- Add types to
types/index.tsif needed - Update store if state needed
- Update API client if new endpoints
- Test with mock data locally
- Test with real WordPress instance