Files
classroomio/apps/dashboard/ai/PAGE_ROUTE_GUIDE.md
T
Rotimi BestandAbdulmuizz Hamzat 9fb6c7cbaa feat: migrate course UI from supabase (#615)
* chore: fix storybook build

* chore: implement theming

* community list not showing properly

* refactor: migrate Chip component to @cio/ui/custom/chip

- Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package.
- Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality.
- Adjusted related components to ensure consistent usage of the updated Chip component.

* fix: align course sidebar design to match organization design (#611)

* fix: align course sidebar design to match organization design

* feat: add hideSearch prop to AppHeader and update sidebar layout

---------

Co-authored-by: Rotimi Best <best@classroomio.com>

* refactor: replace PrimaryButton with Button component across multiple files

- Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton.
- Ensured consistent button usage throughout the application for improved styling and functionality.
- Removed the PrimaryButton component and its associated constants from the codebase.

* refactor: standardize button and input usage across components

- Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton).
- Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions.
- Removed unused components and streamlined imports to enhance code maintainability and readability.

* chore: themeing, course card, use $features

* refactor: centralize UI components and update modal implementations

- Refactored various components to centralize UI elements, improving discoverability and maintainability.
- Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application.
- Removed unused components and streamlined imports to enhance code clarity and reduce complexity.

* feat: add new documentation and refactor org and lms UI components

- Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference.
- Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality.
- Removed deprecated components and streamlined imports for better maintainability.
- Updated translations across multiple languages to ensure consistency in user experience.

* refactor: update UI components and improve styling consistency

- Refactored various components to enhance styling consistency, including updates to button and text color classes.
- Removed deprecated components and streamlined imports for better maintainability.
- Replaced instances of PageBody with a more flexible Page component structure across multiple routes.
- Updated translations and UI elements to align with the new design system.
- Deleted unused components to reduce code complexity and improve clarity.

* update output.css

---------

Co-authored-by: Abdulmuizz Hamzat <103611443+canhamzacode@users.noreply.github.com>
2025-12-22 02:36:48 +01:00

22 KiB

Page and Route Creation Guide

This guide explains how to create pages and routes in the dashboard application, following the established patterns and architecture.

Table of Contents

  1. Route Structure Overview
  2. Creating Basic Pages
  3. Creating Settings Pages
  4. Creating Feature Pages
  5. Layout Patterns
  6. Form Patterns (Field.Group)
  7. Navigation Setup
  8. Breadcrumb Setup

Route Structure Overview

The dashboard has two main route contexts:

1. Organization Routes (/org/[slug]/...)

  • Uses OrgSidebar and AppHeader
  • Path pattern: /org/[slug]/[feature]
  • Example: /org/acme/courses, /org/acme/settings

2. LMS Routes (/lms/...)

  • Uses LMSSidebar and LmsHeader
  • Path pattern: /lms/[feature]
  • Example: /lms/community, /lms/settings

Creating Basic Pages

Step 1: Create the Route File

Location: apps/dashboard/src/routes/[context]/[feature]/+page.svelte

Pattern:

<script lang="ts">
  import { FeaturePage } from '$features/[feature]/pages';
  import { FeatureActionButton } from '$features/[feature]/components';
  import { t } from '$lib/utils/functions/translations';
  import * as Page from '@cio/ui/base/page';
</script>

<svelte:head>
  <title>Feature Name - ClassroomIO</title>
</svelte:head>

<Page.Root class="w-full">
  <Page.Header>
    <Page.HeaderContent>
      <Page.Title>{$t('feature.title')}</Page.Title>
    </Page.HeaderContent>
    <Page.Action>
      <FeatureActionButton />
    </Page.Action>
  </Page.Header>
  <Page.Body>
    {#snippet child()}
      <FeaturePage />
    {/snippet}
  </Page.Body>
</Page.Root>

Step 2: Create the Feature Page Component

Location: apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte

Pattern:

<script lang="ts">
  import { Search } from '@cio/ui/custom/search';
  import { Empty } from '@cio/ui/custom/empty';
  import * as Page from '@cio/ui/base/page';
  import * as Select from '@cio/ui/base/select';
  import SomeIcon from '@lucide/svelte/icons/...';
  import { FeatureCard, FeatureLoader } from '../components';
  import { t } from '$lib/utils/functions/translations';

  let isLoading = $state(false);
  let searchValue = $state('');
  let data = $state([]);
</script>

<Page.BodyHeader align="right">
  <Search placeholder="Search..." bind:value={searchValue} />
  <Select.Root>
    <!-- Select options -->
  </Select.Root>
</Page.BodyHeader>

<div class="w-full">
  {#if isLoading}
    <FeatureLoader />
  {:else if !data.length}
    <Empty
      title={$t('feature.empty_title')}
      description={$t('feature.empty_description')}
      icon={SomeIcon}
      variant="page"
    >
      <FeatureActionButton />
    </Empty>
  {:else}
    {#each data as item}
      <FeatureCard {item} />
    {/each}
  {/if}
</div>

Step 3: Export the Page

Location: apps/dashboard/src/lib/features/[feature]/pages/index.ts

export { default as FeaturePage } from './feature.svelte';

Creating Settings Pages

Settings pages follow a specific pattern with layouts and subroutes.

Step 1: Create Settings Layout

Location: apps/dashboard/src/routes/[context]/settings/+layout.svelte

<script lang="ts">
  interface Props {
    children?: import('svelte').Snippet;
  }

  let { children }: Props = $props();
  import * as Page from '@cio/ui/base/page';
</script>

<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
  {@render children?.()}
</Page.Root>

Step 2: Create Default Settings Page

Location: apps/dashboard/src/routes/[context]/settings/+page.svelte

<script lang="ts">
  import { ProfilePage } from '$features/settings/pages';
  import { t } from '$lib/utils/functions/translations';
  import { Button } from '@cio/ui/base/button';
  import * as Page from '@cio/ui/base/page';

  let profileComponent: ProfilePage | null = $state(null);
  let isLoading = $state(false);

  async function handleUpdate() {
    isLoading = true;
    try {
      await profileComponent?.handleUpdate();
    } finally {
      isLoading = false;
    }
  }
</script>

<svelte:head>
  <title>Settings - ClassroomIO</title>
</svelte:head>

<Page.Header>
  <Page.HeaderContent>
    <Page.Title>{$t('settings.heading')}</Page.Title>
  </Page.HeaderContent>
  <Page.Action>
    <Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
      {$t('settings.profile.update_profile')}
    </Button>
  </Page.Action>
</Page.Header>
<Page.Body>
  {#snippet child()}
    <ProfilePage bind:this={profileComponent} />
  {/snippet}
</Page.Body>

Step 3: Create Settings Subroute

Location: apps/dashboard/src/routes/[context]/settings/[subroute]/+page.svelte

<script lang="ts">
  import { SubroutePage } from '$features/settings/pages';
  import { t } from '$lib/utils/functions/translations';
  import * as Page from '@cio/ui/base/page';
</script>

<svelte:head>
  <title>Subroute Name - ClassroomIO</title>
</svelte:head>

<Page.Header>
  <Page.HeaderContent>
    <Page.Title>{$t('settings.subroute.heading')}</Page.Title>
  </Page.HeaderContent>
</Page.Header>
<Page.Body>
  {#snippet child()}
    <SubroutePage />
  {/snippet}
</Page.Body>

Note: Settings pages use subroutes instead of tabs. Each subroute has its own +page.svelte file.


Creating Feature Pages

Feature Page Structure

Feature pages contain all business logic and should be placed in lib/features/[feature]/pages/.

Key Principles:

  • ✅ All business logic goes in the feature page
  • ✅ Use Page.BodyHeader for filters, search, and view toggles
  • ✅ Use Empty component for empty states
  • ✅ Use Search component for search inputs
  • ✅ Handle loading states with skeleton/loader components
  • ❌ Don't use Page.Header or Page.Action in feature pages (these are in route files)

Example Feature Page

<script lang="ts">
  import { untrack } from 'svelte';
  import { Search } from '@cio/ui/custom/search';
  import { Empty } from '@cio/ui/custom/empty';
  import * as Page from '@cio/ui/base/page';
  import * as Select from '@cio/ui/base/select';
  import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
  import { CommunityListLoader, AskCommunityButton } from '../components';
  import { t } from '$lib/utils/functions/translations';
  import { profile } from '$lib/utils/store/user';
  import { currentOrg } from '$lib/utils/store/org';

  let isLoading = $state(false);
  let searchValue = $state('');
  let discussions = $state([]);
  let selectedId = $state('');

  // Data fetching logic
  async function fetchData() {
    isLoading = true;
    // ... fetch logic
    isLoading = false;
  }

  $effect(() => {
    if ($profile.id && $currentOrg.id) {
      fetchData();
    }
  });
</script>

<Page.BodyHeader align="right">
  <Search placeholder="Search..." bind:value={searchValue} />
  <Select.Root bind:value={selectedId}>
    <Select.Trigger>
      <Select.Value placeholder="Filter..." />
    </Select.Trigger>
    <Select.Content>
      <Select.Item value="all">All</Select.Item>
      <!-- More options -->
    </Select.Content>
  </Select.Root>
</Page.BodyHeader>

<div class="w-full">
  {#if isLoading}
    <CommunityListLoader />
    <CommunityListLoader />
    <CommunityListLoader />
  {:else if !discussions.length}
    <Empty
      title={$t('community.no_question')}
      description={$t('community.ask_a_question')}
      icon={MessageSquareMoreIcon}
      variant="page"
    >
      <AskCommunityButton />
    </Empty>
  {:else}
    {#each discussions as discussion}
      <!-- Discussion content -->
    {/each}
  {/if}
</div>

Layout Patterns

Organization Layout

Location: apps/dashboard/src/routes/org/[slug]/+layout.svelte

<script lang="ts">
  import * as Sidebar from '@cio/ui/base/sidebar';
  import { AppHeader } from '$features/ui';
  import { OrgSidebar } from '$features/ui/sidebar/org-sidebar';

  let { data, children } = $props();
</script>

<Sidebar.Provider>
  <OrgSidebar />

  <Sidebar.Inset>
    <AppHeader />

    <div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
      {@render children?.()}
    </div>
  </Sidebar.Inset>
</Sidebar.Provider>

LMS Layout

Location: apps/dashboard/src/routes/lms/+layout.svelte

<script lang="ts">
  import * as Sidebar from '@cio/ui/base/sidebar';
  import LmsHeader from '$features/ui/navigation/lms-header.svelte';
  import { LMSSidebar } from '$features/ui/sidebar/lms-sidebar';

  let { children }: Props = $props();
</script>

<Sidebar.Provider>
  <LMSSidebar />

  <Sidebar.Inset>
    <LmsHeader />

    <div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 px-4">
      {@render children?.()}
    </div>
  </Sidebar.Inset>
</Sidebar.Provider>

Key Differences:

  • Org routes use AppHeader (includes AppSetup)
  • LMS routes use LmsHeader (no AppSetup)
  • Org routes use OrgSidebar
  • LMS routes use LMSSidebar

Form Patterns (Field.Group)

IMPORTANT: Always use Field.Group pattern for forms. The old Row/Grid/Column pattern is deprecated and removed.

Field.Group Pattern

Location: apps/dashboard/src/lib/features/[feature]/pages/[feature].svelte

<script lang="ts">
  import * as Field from '@cio/ui/base/field';
  import { Input } from '@cio/ui/base/input';
  import { Button } from '@cio/ui/base/button';
  import { Switch } from '@cio/ui/base/switch';
  import { UploadImage, UnsavedChanges } from '$features/ui';
  import { t } from '$lib/utils/functions/translations';

  let hasUnsavedChanges = $state(false);
  let formData = $state({
    name: '',
    email: '',
    enabled: false
  });

  export async function handleSave() {
    // Save logic
    hasUnsavedChanges = false;
  }
</script>

<UnsavedChanges bind:hasUnsavedChanges />

<Field.Group class="max-w-md! w-full px-2">
  <Field.Set>
    <Field.Legend>{$t('form.section_title')}</Field.Legend>
    <Field.Group>
      <Field.Field>
        <Field.Label>{$t('form.name')}</Field.Label>
        <Input bind:value={formData.name} oninput={() => (hasUnsavedChanges = true)} />
      </Field.Field>
      <Field.Field>
        <Field.Label>{$t('form.email')}</Field.Label>
        <Input bind:value={formData.email} type="email" oninput={() => (hasUnsavedChanges = true)} />
      </Field.Field>
      <Field.Field orientation="horizontal">
        <Switch bind:checked={formData.enabled} onCheckedChange={() => (hasUnsavedChanges = true)} />
        <Field.Label>{$t('form.enabled')}</Field.Label>
      </Field.Field>
    </Field.Group>
  </Field.Set>

  <Field.Separator />

  <Field.Set>
    <Field.Legend>{$t('form.another_section')}</Field.Legend>
    <Field.Field>
      <UploadImage
        bind:avatar
        src={avatarUrl}
        change={() => (hasUnsavedChanges = true)}
      />
    </Field.Field>
  </Field.Set>
</Field.Group>

Field Components

  • <Field.Group> - Main container (use class="max-w-md! w-full px-2")
  • <Field.Set> - Section container
  • <Field.Legend> - Section title
  • <Field.Field> - Individual field wrapper
  • <Field.Label> - Field label
  • <Field.Description> - Field description/help text
  • <Field.Error> - Field error message
  • <Field.Separator /> - Visual separator between sections
  • orientation="horizontal" - For switches/checkboxes with labels side-by-side

Pattern Rules

  • ✅ DO: Use Field.Group as the main container
  • ✅ DO: Use Field.Set for logical sections
  • ✅ DO: Use Field.Separator between sections
  • ✅ DO: Use Field.Legend for section titles
  • ✅ DO: Wrap form inputs in Field.Field
  • ✅ DO: Use Field.Label for input labels
  • ✅ DO: Use orientation="horizontal" for switches/checkboxes
  • ❌ DON'T: Use Row, Grid, or Column components (deprecated)
  • ❌ DON'T: Mix Field.Group with old layout components

Navigation Setup

Adding Items to Organization Navigation

Location: apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts

const baseNavConfig: NavItemConfig[] = [
  // ... existing items
  {
    titleKey: 'org_navigation.new_feature',
    path: '/new-feature',
    icon: NewFeatureIcon,
    matchPattern: '^/org/[^/]+/new-feature(/.*)?$',
    // Optional: requires admin access
    requiresAdmin: true,
    // Optional: nested routes
    nestedRoutes: [
      {
        path: 'subroute',
        titleKey: 'Subroute Name'
      }
    ],
    // Optional: supports dynamic segments
    supportsDynamicSegment: true
  }
];

Adding Items to LMS Navigation

Location: apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts

const baseNavConfig: NavItemConfig[] = [
  // ... existing items
  {
    titleKey: 'lms_navigation.new_feature',
    path: '/new-feature',
    icon: NewFeatureIcon,
    matchPattern: '^/lms/new-feature(/.*)?$',
    // Optional: conditional display
    show: (currentOrg) => currentOrg?.customization?.feature?.enabled === true,
    // Optional: nested routes
    nestedRoutes: [
      {
        path: 'subroute',
        titleKey: 'Subroute Name'
      }
    ],
    // Optional: supports dynamic segments
    supportsDynamicSegment: true
  }
];

Navigation Item Properties

  • titleKey - Translation key for the navigation label
  • path - Route path (relative to context)
  • icon - Lucide icon component
  • matchPattern - Regex pattern for active state matching
  • requiresAdmin - Only show for admin users (org navigation only)
  • show - Function to conditionally show/hide item
  • nestedRoutes - Static nested routes (like community/ask)
  • supportsDynamicSegment - Supports dynamic routes (like community/[slug])
  • useHashUrl - Use '#' for collapsible parent (settings menu)
  • items - Nested items for collapsible menus (settings sub-items)

Breadcrumb Setup

Breadcrumbs are automatically generated from navigation configuration.

For Organization Routes

Breadcrumbs use generateBreadcrumbs() from breadcrumb.ts which reads from org-navigation.ts.

For LMS Routes

Breadcrumbs use generateLmsBreadcrumbs() from lms-breadcrumb.ts which reads from lms-navigation.ts.

Adding Dynamic Breadcrumbs

For dynamic routes (like community/[slug]), add breadcrumb data in the route's +page.ts:

// routes/org/[slug]/community/[slug]/+page.ts
export async function load({ params }) {
  // Fetch question data
  const question = await fetchQuestion(params.slug);
  
  return {
    breadcrumb: question.title // This will appear in breadcrumbs
  };
}

Complete Examples

Example 1: Simple Feature Page

Route: routes/org/[slug]/courses/+page.svelte

<script lang="ts">
  import { CoursesPage } from '$features/courses/pages';
  import { t } from '$lib/utils/functions/translations';
  import * as Page from '@cio/ui/base/page';
</script>

<svelte:head>
  <title>Courses - ClassroomIO</title>
</svelte:head>

<Page.Root class="w-full">
  <Page.Header>
    <Page.HeaderContent>
      <Page.Title>{$t('courses.title')}</Page.Title>
    </Page.HeaderContent>
  </Page.Header>
  <Page.Body>
    {#snippet child()}
      <CoursesPage />
    {/snippet}
  </Page.Body>
</Page.Root>

Example 2: Settings Page with Save Action

Route: routes/org/[slug]/settings/customize-lms/+page.svelte

<script lang="ts">
  import { CustomizeLmsPage } from '$features/settings/pages';
  import { t } from '$lib/utils/functions/translations';
  import { Button } from '@cio/ui/base/button';
  import * as Page from '@cio/ui/base/page';

  let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
  let isSaving = $state(false);

  async function handleSave() {
    isSaving = true;
    try {
      await customizeLmsComponent?.handleSave();
    } finally {
      isSaving = false;
    }
  }
</script>

<svelte:head>
  <title>Customize LMS - ClassroomIO</title>
</svelte:head>

<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
  <Page.Header>
    <Page.HeaderContent>
      <Page.Title>{$t('components.settings.customize_lms.title')}</Page.Title>
    </Page.HeaderContent>
    <Page.Action>
      <Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
        {$t('components.settings.customize_lms.save')}
      </Button>
    </Page.Action>
  </Page.Header>
  <Page.Body>
    {#snippet child()}
      <CustomizeLmsPage bind:this={customizeLmsComponent} />
    {/snippet}
  </Page.Body>
</Page.Root>

Example 3: Form Page Component

Feature Page: lib/features/settings/pages/profile.svelte

<script lang="ts">
  import { profile } from '$lib/utils/store/user';
  import { profileApi } from '$features/auth/api/profile.svelte';
  import { t } from '$lib/utils/functions/translations';
  import { Input } from '@cio/ui/base/input';
  import { UploadImage, UnsavedChanges } from '$features/ui';
  import * as Field from '@cio/ui/base/field';

  let avatar = $state<string | File | undefined>();
  let hasUnsavedChanges = $state(false);

  export async function handleUpdate() {
    await profileApi.submit({
      fullname: $profile.fullname,
      username: $profile.username,
      avatar
    }, $profile);

    if (profileApi.success) {
      hasUnsavedChanges = false;
      avatar = undefined;
    }
  }
</script>

<UnsavedChanges bind:hasUnsavedChanges />

<Field.Group class="max-w-md! w-full px-2">
  <Field.Set>
    <Field.Legend>{$t('settings.profile.profile_picture.heading')}</Field.Legend>
    <Field.Field>
      <UploadImage
        bind:avatar
        src={$profile.avatarUrl}
        widthHeight="w-16 h-16 lg:w-24 lg:h-24"
        change={() => (hasUnsavedChanges = true)}
      />
    </Field.Field>
  </Field.Set>

  <Field.Separator />

  <Field.Set>
    <Field.Legend>{$t('settings.profile.personal_information.heading')}</Field.Legend>
    <Field.Group>
      <Field.Field>
        <Field.Label>{$t('settings.profile.personal_information.full_name')}</Field.Label>
        <Input bind:value={$profile.fullname} oninput={() => (hasUnsavedChanges = true)} />
        {#if profileApi.errors.fullname}
          <Field.Error>{$t(profileApi.errors.fullname)}</Field.Error>
        {/if}
      </Field.Field>
      <!-- More fields -->
    </Field.Group>
  </Field.Set>
</Field.Group>

Checklist for New Pages

When creating a new page, follow this checklist:

Route Setup

  • Create route file at routes/[context]/[feature]/+page.svelte
  • Add Page.Root, Page.Header, Page.Body structure
  • Add <svelte:head> with page title
  • Import and render feature page component

Feature Page

  • Create feature page at lib/features/[feature]/pages/[feature].svelte
  • Export from lib/features/[feature]/pages/index.ts
  • Use Page.BodyHeader for filters/search
  • Use Empty component for empty states
  • Handle loading states

Forms (if applicable)

  • Use Field.Group pattern (NOT Row/Grid/Column)
  • Use Field.Set for sections
  • Use Field.Separator between sections
  • Use Field.Legend for section titles
  • Wrap inputs in Field.Field
  • Add UnsavedChanges component if needed

Navigation (if needed)

  • Add item to org-navigation.ts or lms-navigation.ts
  • Set appropriate matchPattern for active state
  • Add icon component
  • Configure nested routes if needed
  • Configure dynamic segments if needed

Settings Pages (if applicable)

  • Create +layout.svelte with Page.Root wrapper
  • Create default +page.svelte for main settings
  • Create subroute +page.svelte files for each subroute
  • Export page components from $features/settings/pages

Common Patterns

Page Width Classes

  • class="w-full" - Full width
  • class="w-full md:max-w-3xl lg:mx-auto" - Centered, max width on larger screens (settings pages)
  • class="w-full md:max-w-4xl lg:mx-auto" - Slightly wider (customize-lms)
  • class="mx-auto w-full max-w-6xl" - Wide content area (main layouts)

Page Actions

Actions in Page.Action typically include:

  • Save/Update buttons
  • Create/Add buttons
  • Export buttons
  • Feature-specific action buttons

Loading States

Use skeleton loaders or spinner components:

{#if isLoading}
  <FeatureLoader />
  <FeatureLoader />
  <FeatureLoader />
{:else}
  <!-- Content -->
{/if}

Empty States

Always use the Empty component:

<Empty
  title={$t('feature.empty_title')}
  description={$t('feature.empty_description')}
  icon={SomeIcon}
  variant="page"
>
  <ActionButton />
</Empty>

Best Practices

  1. Separation of Concerns: Route files handle routing structure, feature pages handle business logic
  2. Reusability: Feature pages can be imported and used in different contexts
  3. Consistency: All pages follow the same structure for maintainability
  4. Design System: Use established components (Page.*, Field.*, Empty, Search)
  5. State Management: Keep state local to feature pages or use feature-specific stores
  6. Translation Keys: Always use translation keys from $t() function
  7. Type Safety: Use TypeScript types for props and state
  8. Accessibility: Use semantic HTML and proper ARIA labels

Migration from Old Patterns

If you encounter old patterns, migrate them:

Old: Row/Grid/Column Pattern

<!-- OLD - DON'T USE -->
<Grid>
  <Row>
    <Column sm={4} md={4} lg={4}>
      <SectionTitle>Title</SectionTitle>
    </Column>
    <Column sm={8} md={8} lg={8}>
      <InputField />
    </Column>
  </Row>
</Grid>

New: Field.Group Pattern

<!-- NEW - USE THIS -->
<Field.Group class="max-w-md! w-full px-2">
  <Field.Set>
    <Field.Legend>Title</Field.Legend>
    <Field.Field>
      <Field.Label>Label</Field.Label>
      <Input />
    </Field.Field>
  </Field.Set>
</Field.Group>

Additional Resources

  • See ROUTE_PAGE_SETUP.md for more details on feature page structure
  • See existing pages in routes/org/[slug]/ and routes/lms/ for examples
  • See lib/features/settings/pages/ for form examples
  • See lib/features/community/pages/ for list page examples