On this page ...

A Sorted Table

The native Freon Table takes a list of nodes of the same type and displays their properties in columns or rows. But what if you want something different? In this example we sort a schedule’s list of time slots by their TimeStamp (day/part) before displaying the schedule.

As a reminder, here are the AST definitions of Schedule, Slot and TimeStamp.

// CourseSchedule/phase5/defs/main.ast#L10-L14

modelunit Schedule {
    name: identifier;
    timeSlots: Slot[];
    file-extension = "scd";     // the file extension used by the parser
}
// CourseSchedule/phase5/defs/main.ast#L33-L53

concept Slot {
    time: TimeStamp;
    reference teacher: Person;
    reference room: Room;
    reference course: Course;
}

limited TimeStamp {
    day: number; // 1 = Monday, 2 = Tuesday, etc
    part: number; // 1 indicates morning, 2 indicates afternoon
    MondayMorning = { day: 1, part: 1 }
    TuesdayMorning = { day: 2, part: 1 }
    WednesdayMorning = { day: 3, part: 1 }
    ThursdayMorning = { day: 4, part: 1 }
    FridayMorning = { day: 5, part: 1 }
    MondayAfternoon = { day: 1, part: 2 }
    TuesdayAfternoon = { day: 2, part: 2 }
    WednesdayAfternoon = { day: 3, part: 2 }
    ThursdayAfternoon = { day: 4, part: 2 }
    FridayAfternoon = { day: 5, part: 2 }
}

Step 1: Create the Svelte Component

To create a dynamic schedule table, we define a Svelte component named Schedule.svelte. This component handles sorting the time slots, displays the sorted grid, and lets the user add new slots.

The Script Section

The box type we use is a PartListReplacerBox. The four mandatory functions are similar to the ones in the StaffAccordion example, with one exception explained later. The key to sorting is the initialize() function, which processes the list of timeSlots and sorts them by TimeStamp. While sorting, we remember which child box is associated with which Slot in slotToBoxMap so we can render the correct child box.

// CourseSchedule/phase5/src/external/Schedule.svelte#L56-L156

    TimeStamp.TuesdayAfternoon,
    TimeStamp.WednesdayAfternoon,
    TimeStamp.ThursdayAfternoon,
    TimeStamp.FridayAfternoon
];

function sortSlots(startVal: Slot[]) {
    const newSlots: Slot[][] = []
    for (let i = 0; i < 10 ; i++) {
        newSlots[i] = [];
    }
    (startVal).forEach((val, index) => {
        // remember which box belongs to which slot
        slotToBoxMap.set(val, box.children[index]);
        switch (val.$time.day) {
            case 1: {
                switch (val.$time.part) {
                    case 1: { // Monday morning
                        newSlots[0].push(val);
                        break;
                    }
                    case 2: { // Monday afternoon
                        newSlots[5].push(val);
                        break;
                    }
                    default: {
                        newSlots[0].push(val);
                    }
                }
                break;
            }
            case 2: {
                switch (val.$time.part) {
                    case 1: { // Tuesday morning
                        newSlots[1].push(val);
                        break;
                    }
                    case 2: { // Tuesday afternoon
                        newSlots[6].push(val);
                        break;
                    }
                    default: {
                        newSlots[1].push(val);
                    }
                }
                break;
            }
            case 3: {
                switch (val.$time.part) {
                    case 1: { // Wednesday morning
                        newSlots[2].push(val);
                        break;
                    }
                    case 2: { // Wednesday afternoon
                        newSlots[7].push(val);
                        break;
                    }
                    default: {
                        newSlots[2].push(val);
                    }
                }
                break;
            }
            case 4: {
                switch (val.$time.part) {
                    case 1: { // Thursday morning
                        newSlots[3].push(val);
                        break;
                    }
                    case 2: { // Thursday afternoon
                        newSlots[8].push(val);
                        break;
                    }
                    default: {
                        newSlots[3].push(val);
                    }
                }
                break;
            }
            case 5: {
                switch (val.$time.part) {
                    case 1: { // Friday morning
                        newSlots[4].push(val);
                        break;
                    }
                    case 2: { // Friday afternoon
                        newSlots[9].push(val);
                        break;
                    }
                    default: {
                        newSlots[4].push(val);
                    }
                }
                break;
            }
        }
    })
    sortedSlots = newSlots
}

/* Sort the list of slots based on the time */

The function that adds a new Slot takes a TimeStamp parameter, allowing us to create a new slot for the specified time. We didn’t include a “remove slot” function here; it would be similar to the one in StaffAccordion.

// CourseSchedule/phase5/src/external/Schedule.svelte#L158-L165








Then there are two variables that make life easier in the HTML part.

// CourseSchedule/phase5/src/external/Schedule.svelte#L40-L54

    for (let i = 0; i < 10; i++) {
        slots[i] = [];
    }
    return slots;
}

let dayTitle: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday' ];

// variables for creating a new slot
let timeStamps: TimeStamp[] = [
    TimeStamp.MondayMorning,
    TimeStamp.TuesdayMorning,
    TimeStamp.WednesdayMorning,
    TimeStamp.ThursdayMorning,
    TimeStamp.FridayMorning,

The HTML Section

We couldn’t use the Table component from the library because it clips its content (including dropdowns). With a bit of CSS we recreated a table in plain HTML.

First, the headers: each holds a day name. Because we also display row headers (Morning and Afternoon), the first header cell is empty.

// CourseSchedule/phase5/src/external/Schedule.svelte#L193-L203

<div class="demo-table-container">
    <table class="demo-table">
        <thead>
        <tr class="demo-header-row">
            <th class="demo-header-cell"></th>
            {#each dayTitle as title}
                <th class="demo-header-cell">{title}</th>
            {/each}
        </tr>
        </thead>
        <tbody>

Next, we create two rows—one for mornings and one for afternoons.
For the morning row: the first cell is the row header (Morning). We then loop over sortedSlots but only take the first five entries (the mornings). Each morning can contain a list of slots. For each slot we resolve its child box via slotToBoxMap and render it with Freon’s <RenderComponent>. The afternoon row mirrors this but uses the last five entries.

// CourseSchedule/phase5/src/external/Schedule.svelte#L204-L225

<tr class="demo-row">
    <td class="demo-header-cell">Morning</td>
    {#each sortedSlots as slots, index}
        {#if index < 5}
            {#if slots.length > 0}
                <td class="demo-cell">
                    <div class="demo-cell-content">
                    {#each slots as slot}
                        <div class="demo-slot-render">
                        <RenderComponent box={findBoxForSlot(slot)} editor={editor} />
                        </div>
                    {/each}
                    </div>
                </td>
            {:else}
                <td class="demo-cell">
                    <div class="demo-slot-render">NONE</div>
                </td>
            {/if}
        {/if}
    {/each}
</tr>

Between the two time rows we add button rows so the user can create a slot for a specific time. Again, the first cell is empty (row header column). We loop over timeStamps and pass each value to addSlot.

// CourseSchedule/phase5/src/external/Schedule.svelte#L226-L237

<tr>
    <td class="demo-btn-cell"></td>
    {#each timeStamps as stamp, index}
        {#if index < 5}
            <td class="demo-btn-cell">
                <Button tabindex={-1} id="add-button" class="{buttonCls} {colorCls} " name="ToastOpen" onclick={() => addSlot(stamp)}>
                    <UserAddOutline class="{iconCls}" />
                </Button>
            </td>
        {/if}
    {/each}
</tr>

The complete Svelte component is at the bottom of this page.

Step 2: Include in the Projection

We include the new component in the projection with replace=Schedule. Because the table already conveys each slot’s time, we hide \${self.time} in the Slot projection.

// CourseSchedule/phase5/defs/externals.edit#L20-L31

Schedule {[
Schedule ${self.name}

${self.timeSlots replace=Schedule}

]}

Slot {[
    Teacher: ${self.teacher}
    Room:    ${self.room}
    Course:  ${self.course}
]}

By now you know the small amount of admin needed to register an external component, so we won’t repeat it here.

Final Result

When all is done, the editor should look like this. Neat, right?

Image 'examples/CourseSchedule/Screenshot-step5.png' seems to be missing
Figure 1. Editor with sorted table showing Slots

Conclusion

After following these steps, you’ll have a Svelte component that displays a sorted table of time slots. This approach adapts well to any case where you want to preprocess and present structured data in a table. The editor shows the schedule neatly sorted by day and time, and users can interactively add slots.

This extended example demonstrates how custom Svelte components in the Freon editor unlock many design and UX possibilities. Once you’re comfortable with this pattern, you can reuse it with any of the External Component Box Types.

Since external components are still experimental, we’re eager to learn how you plan to use them. If you decide to incorporate them, please reach out to the Freon team at info@openmodeling.nl or via GitHub — we’re happy to help.

The Complete Svelte Component

For reference, here is the full implementation of the Schedule.svelte component:

// CourseSchedule/phase5/src/external/Schedule.svelte

<script lang="ts">
    import {
        Box,
        PartListReplacerBox,
        type FreNode,
        FreNodeReference,
        AST, isNullOrUndefined, LabelBox, notNullOrUndefined
    } from "@freon4dsl/core"
    import {type FreComponentProps, RenderComponent} from "@freon4dsl/core-svelte";
    import {Slot, TimeStamp} from "../freon/index.js";
    import { UserAddOutline } from 'flowbite-svelte-icons';
    import { Button } from 'flowbite-svelte';

    // This component replaces the component for "timeSlots: Slot[];" from model unit "Schedule".
    // This property is a parts list, therefore the external box to use is an PartListReplacerBox.
    // Props
    let { editor, box }: FreComponentProps<PartListReplacerBox> = $props();

    // The following three functions need to be included for the editor to function properly.
    // Please, set the focus to the first editable/selectable element in this component.
    async function setFocus(): Promise<void> {
    }
    const refresh = (why?: string): void => {
        // do whatever needs to be done to refresh the elements that show information from the model
        initialize();
    };
    $effect(() => {
        initialize();
        box.setFocus = setFocus;
        box.refreshComponent = refresh;
    });

    // --------------------------- //
    let slotToBoxMap: Map<Slot, Box> = new Map<Slot, Box>();
    // an array of 10 positions, making use of the 10 different timeSlots that are available
    let sortedSlots: Slot[][] = $state(initSortedSlots());

    function initSortedSlots(): Slot[][] {
        let slots = [];
        for (let i = 0; i < 10; i++) {
            slots[i] = [];
        }
        return slots;
    }

    let dayTitle: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday' ];

    // variables for creating a new slot
    let timeStamps: TimeStamp[] = [
        TimeStamp.MondayMorning,
        TimeStamp.TuesdayMorning,
        TimeStamp.WednesdayMorning,
        TimeStamp.ThursdayMorning,
        TimeStamp.FridayMorning,
        TimeStamp.MondayAfternoon,
        TimeStamp.TuesdayAfternoon,
        TimeStamp.WednesdayAfternoon,
        TimeStamp.ThursdayAfternoon,
        TimeStamp.FridayAfternoon
    ];

    function sortSlots(startVal: Slot[]) {
        const newSlots: Slot[][] = []
        for (let i = 0; i < 10 ; i++) {
            newSlots[i] = [];
        }
        (startVal).forEach((val, index) => {
            // remember which box belongs to which slot
            slotToBoxMap.set(val, box.children[index]);
            switch (val.$time.day) {
                case 1: {
                    switch (val.$time.part) {
                        case 1: { // Monday morning
                            newSlots[0].push(val);
                            break;
                        }
                        case 2: { // Monday afternoon
                            newSlots[5].push(val);
                            break;
                        }
                        default: {
                            newSlots[0].push(val);
                        }
                    }
                    break;
                }
                case 2: {
                    switch (val.$time.part) {
                        case 1: { // Tuesday morning
                            newSlots[1].push(val);
                            break;
                        }
                        case 2: { // Tuesday afternoon
                            newSlots[6].push(val);
                            break;
                        }
                        default: {
                            newSlots[1].push(val);
                        }
                    }
                    break;
                }
                case 3: {
                    switch (val.$time.part) {
                        case 1: { // Wednesday morning
                            newSlots[2].push(val);
                            break;
                        }
                        case 2: { // Wednesday afternoon
                            newSlots[7].push(val);
                            break;
                        }
                        default: {
                            newSlots[2].push(val);
                        }
                    }
                    break;
                }
                case 4: {
                    switch (val.$time.part) {
                        case 1: { // Thursday morning
                            newSlots[3].push(val);
                            break;
                        }
                        case 2: { // Thursday afternoon
                            newSlots[8].push(val);
                            break;
                        }
                        default: {
                            newSlots[3].push(val);
                        }
                    }
                    break;
                }
                case 5: {
                    switch (val.$time.part) {
                        case 1: { // Friday morning
                            newSlots[4].push(val);
                            break;
                        }
                        case 2: { // Friday afternoon
                            newSlots[9].push(val);
                            break;
                        }
                        default: {
                            newSlots[4].push(val);
                        }
                    }
                    break;
                }
            }
        })
        sortedSlots = newSlots
    }

    /* Sort the list of slots based on the time */
    function initialize() {
        let startVal: FreNode[] | undefined = box.getPropertyValue();
        if (notNullOrUndefined(startVal) && box.getPropertyType() === "Slot") {
            // cast the startVal to the expected type, in this case "Slot[]".
            // sort the slots based on the time and remember which box belongs to which slot
            sortSlots(startVal as Slot[]);
        }
    }

    const addSlot = (timeStamp: TimeStamp) => {
        // Note that you need to put any changes to the actual model in a 'AST.change' or 'AST.changeNamed',
        // because all elements in the model are reactive using mobx.
        AST.change(() => {
            let newSlot: Slot = Slot.create({time: FreNodeReference.create<TimeStamp>(timeStamp, "TimeStamp")});
            box.getPropertyValue().push(newSlot);
        });
    }

    const findBoxForSlot = (slot: Slot): Box => {
        let xx = slotToBoxMap.get(slot);
        if (!isNullOrUndefined(xx)) {
            return xx;
        } else {
            return new LabelBox(box.node, 'no-role', () => { return 'No box found'});
        }
    }
    initialize();
    const colorCls: string = 'text-light-base-50 dark:text-dark-base-900 ';
    const buttonCls: string =
      'bg-light-base-600 					dark:bg-dark-base-200 ' +
      'hover:bg-light-base-900 		dark:hover:bg-dark-base-50 ' +
      'border-light-base-100 			dark:border-dark-base-800 ';
    const iconCls: string = 'ms-0 inline h-6 w-6';
</script>


<div class="demo-table-container">
    <table class="demo-table">
        <thead>
        <tr class="demo-header-row">
            <th class="demo-header-cell"></th>
            {#each dayTitle as title}
                <th class="demo-header-cell">{title}</th>
            {/each}
        </tr>
        </thead>
        <tbody>
        <tr class="demo-row">
            <td class="demo-header-cell">Morning</td>
            {#each sortedSlots as slots, index}
                {#if index < 5}
                    {#if slots.length > 0}
                        <td class="demo-cell">
                            <div class="demo-cell-content">
                            {#each slots as slot}
                                <div class="demo-slot-render">
                                <RenderComponent box={findBoxForSlot(slot)} editor={editor} />
                                </div>
                            {/each}
                            </div>
                        </td>
                    {:else}
                        <td class="demo-cell">
                            <div class="demo-slot-render">NONE</div>
                        </td>
                    {/if}
                {/if}
            {/each}
        </tr>
        <tr>
            <td class="demo-btn-cell"></td>
            {#each timeStamps as stamp, index}
                {#if index < 5}
                    <td class="demo-btn-cell">
                        <Button tabindex={-1} id="add-button" class="{buttonCls} {colorCls} " name="ToastOpen" onclick={() => addSlot(stamp)}>
                            <UserAddOutline class="{iconCls}" />
                        </Button>
                    </td>
                {/if}
            {/each}
        </tr>
        <tr>
            <td class="demo-header-cell">Afternoon</td>
            {#each sortedSlots as slots, index}
                {#if index >= 5}
                    {#if slots.length > 0}
                        <td class="demo-cell">
                            {#each slots as slot}
                                <div class="demo-slot-render">
                                <RenderComponent box={findBoxForSlot(slot)} editor={editor} />
                                </div>
                            {/each}
                        </td>
                    {:else}
                        <td class="demo-cell">
                            <div class="demo-slot-render">
                                NONE
                            </div>
                        </td>
                    {/if}
                {/if}
            {/each}
        </tr>
        <tr>
            <td class="demo-btn-cell"></td>
            {#each timeStamps as stamp, index}
                {#if index >= 5}
                    <td class="demo-btn-cell">
                        <Button tabindex={-1} id="add-button" class="{buttonCls} {colorCls} " name="ToastOpen" onclick={() => addSlot(stamp)}>
                            <UserAddOutline class="{iconCls}" />
                        </Button>
                    </td>
                {/if}
            {/each}
        </tr>
        </tbody>
    </table>
</div>


<style>
    .demo-table-container {
        background-color:#fff;
        color: rgba(0, 0, 0, 0.87);
        max-width: 100%;
        border-radius:4px;
        border-width:1px;
        border-style:solid;
        border-color:rgba(0,0,0,.12);
        display:inline-flex;
        flex-direction:column;
        box-sizing:border-box;
        position:relative;
    }
    .demo-table {
        min-width:100%;
        border:0;
        white-space:nowrap;
        border-spacing:0;
        table-layout:fixed;
    }
    .demo-cell {
        height: 200px;
        border-right-width: 1px;
        border-right-style: solid;
        border-right-color: rgba(0,0,0,.12);
    }
    .demo-slot-render {
        margin: 0 4px 20px 4px;
    }
    .demo-header-row {
        height: 56px;
    }
    .demo-header-cell {
        font-size:0.875rem;
        line-height:1.375rem;
        font-weight:bolder;
        box-sizing:border-box;
        text-align:left;
        padding: 0 16px 0 16px;
        border-right-width: 1px;
        border-right-style: solid;
        border-right-color: rgba(0,0,0,.12);
        background-color: var(--mdc-theme-surface, #fff);
    }
    .demo-cell-content {
        justify-content: space-between;
        flex-direction: column;
        display: flex;
    }
    .demo-btn-cell {
        border-bottom-width: 1px;
        border-bottom-style: solid;
        border-bottom-color: rgba(0,0,0,.12);
        border-right-width: 1px;
        border-right-style: solid;
        border-right-color: rgba(0,0,0,.12);
        justify-content: space-between;
    }
</style>
© 2018 - 2025 Freon contributors - Freon is open source under the MIT License.