Portal Community

The Pattern

Instead of rendering controls in a flat list, group them by sectionId, then wrap each group in a card:

// src/players/CardSectionPlayer.tsx
import React, { useMemo } from 'react';
import { FormStateProvider }         from '@atlas-forms/state-react';
import { ControlRenderer, useAtlasForm } from '@atlas-forms/player-components-react';
import { useFormVisibility }         from '@atlas-forms/player-components-react';
import { FormEngine }                from '@atlas-forms/form-engine-js';
import { parseSchema }               from '@atlas-forms/schema-js';
import type { FormSchema, FormControl, FormSection } from '@atlas-forms/types-js';

// Group controls by sectionId
function groupBySection(controls: FormControl[], sections: FormSection[]) {
  const sectionMap = new Map<string, FormControl[]>();
  // Initialise in section order
  sections.forEach(s => sectionMap.set(s.id, []));
  // Distribute controls
  controls.forEach(c => {
    const sid = c.sectionId ?? '__root__';
    if (!sectionMap.has(sid)) sectionMap.set(sid, []);
    sectionMap.get(sid)!.push(c);
  });
  return sectionMap;
}

const CardSectionPlayerInner: React.FC<{ onSubmit: (v: any) => void }> = ({ onSubmit }) => {
  const { engine, values, submitForm, isSubmitting } = useAtlasForm();
  const { isVisible } = useFormVisibility();
  const schema = engine.getSchema();

  const grouped = useMemo(() =>
    groupBySection(schema.controls ?? [], schema.sections ?? []),
    [schema]
  );

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await submitForm();
    onSubmit(values);
  };

  return (
    <form onSubmit={handleSubmit}>
      {(schema.sections ?? []).map(section => {
        const controls = (grouped.get(section.id) ?? []).filter(c => isVisible(c.id));
        if (controls.length === 0) return null;

        return (
          <div key={section.id} className="card" style={{ marginBottom: 24 }}>
            {section.title && (
              <div className="card-header">
                <h3>{section.title}</h3>
                {section.description && <p>{section.description}</p>}
              </div>
            )}
            <div className="card-body">
              {controls.map(control => (
                <ControlRenderer key={control.id} control={control} />
              ))}
            </div>
          </div>
        );
      })}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
};

export const CardSectionPlayer: React.FC<{
  schema: FormSchema | object;
  initialValues?: Record<string, any>;
  onSubmit: (v: any) => void;
}> = ({ schema: raw, initialValues = {}, onSubmit }) => {
  const schema = useMemo(() => parseSchema(raw as object), [raw]);
  const engine = useMemo(() => new FormEngine(schema, initialValues), [schema, initialValues]);

  return (
    <FormStateProvider engine={engine} mode="edit">
      <CardSectionPlayerInner onSubmit={onSubmit} />
    </FormStateProvider>
  );
};

Form Schema for This Pattern

// Each section becomes a card; controls reference section via sectionId
{
  "sections": [
    { "id": "personal",  "title": "Personal Information",  "order": 1 },
    { "id": "contact",   "title": "Contact Details",        "order": 2 },
    { "id": "documents", "title": "Supporting Documents",   "order": 3 }
  ],
  "controls": [
    { "id": "first-name", "type": "text",  "sectionId": "personal",  "order": 1 },
    { "id": "last-name",  "type": "text",  "sectionId": "personal",  "order": 2 },
    { "id": "email",      "type": "email", "sectionId": "contact",   "order": 1 },
    { "id": "phone",      "type": "text",  "sectionId": "contact",   "order": 2 },
    { "id": "id-doc",     "type": "file-upload", "sectionId": "documents", "order": 1 }
  ]
}
Performance with useFormVisibility Call useFormVisibility() once at the top of the inner component and pass isVisible down, rather than calling the hook inside each card section's render. This prevents redundant context reads and keeps the visibility check fast.