* 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>
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
- Route Structure Overview
- Creating Basic Pages
- Creating Settings Pages
- Creating Feature Pages
- Layout Patterns
- Form Patterns (Field.Group)
- Navigation Setup
- Breadcrumb Setup
Route Structure Overview
The dashboard has two main route contexts:
1. Organization Routes (/org/[slug]/...)
- Uses
OrgSidebarandAppHeader - Path pattern:
/org/[slug]/[feature] - Example:
/org/acme/courses,/org/acme/settings
2. LMS Routes (/lms/...)
- Uses
LMSSidebarandLmsHeader - 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.BodyHeaderfor filters, search, and view toggles - ✅ Use
Emptycomponent for empty states - ✅ Use
Searchcomponent for search inputs - ✅ Handle loading states with skeleton/loader components
- ❌ Don't use
Page.HeaderorPage.Actionin 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 (useclass="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 sectionsorientation="horizontal"- For switches/checkboxes with labels side-by-side
Pattern Rules
- ✅ DO: Use
Field.Groupas the main container - ✅ DO: Use
Field.Setfor logical sections - ✅ DO: Use
Field.Separatorbetween sections - ✅ DO: Use
Field.Legendfor section titles - ✅ DO: Wrap form inputs in
Field.Field - ✅ DO: Use
Field.Labelfor input labels - ✅ DO: Use
orientation="horizontal"for switches/checkboxes - ❌ DON'T: Use
Row,Grid, orColumncomponents (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 labelpath- Route path (relative to context)icon- Lucide icon componentmatchPattern- Regex pattern for active state matchingrequiresAdmin- Only show for admin users (org navigation only)show- Function to conditionally show/hide itemnestedRoutes- Static nested routes (likecommunity/ask)supportsDynamicSegment- Supports dynamic routes (likecommunity/[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.Bodystructure - 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.BodyHeaderfor filters/search - Use
Emptycomponent for empty states - Handle loading states
Forms (if applicable)
- Use
Field.Grouppattern (NOT Row/Grid/Column) - Use
Field.Setfor sections - Use
Field.Separatorbetween sections - Use
Field.Legendfor section titles - Wrap inputs in
Field.Field - Add
UnsavedChangescomponent if needed
Navigation (if needed)
- Add item to
org-navigation.tsorlms-navigation.ts - Set appropriate
matchPatternfor active state - Add icon component
- Configure nested routes if needed
- Configure dynamic segments if needed
Settings Pages (if applicable)
- Create
+layout.sveltewithPage.Rootwrapper - Create default
+page.sveltefor main settings - Create subroute
+page.sveltefiles for each subroute - Export page components from
$features/settings/pages
Common Patterns
Page Width Classes
class="w-full"- Full widthclass="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
- Separation of Concerns: Route files handle routing structure, feature pages handle business logic
- Reusability: Feature pages can be imported and used in different contexts
- Consistency: All pages follow the same structure for maintainability
- Design System: Use established components (
Page.*,Field.*,Empty,Search) - State Management: Keep state local to feature pages or use feature-specific stores
- Translation Keys: Always use translation keys from
$t()function - Type Safety: Use TypeScript types for props and state
- 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.mdfor more details on feature page structure - See existing pages in
routes/org/[slug]/androutes/lms/for examples - See
lib/features/settings/pages/for form examples - See
lib/features/community/pages/for list page examples