Views

Views are the building blocks of the front-end experience in Spruce. Every Skill can register Skill Views that are comprised of CardViewControllers. A Skill View also renders a NavigationViewController that is comprised of NavigationButtonViews. By default, the skill’s RootSkillViewController is the first view that is rendered.

A CardViewController has a CardHeader, CardBody, and CardFooter. The CardBody is comprised of many CardSections. See the diagrams below to understand how cards are constructed.

Every other type of ViewController (listed below) is rendered inside a CardSection. This allows for a consistent look and feel across all views in the Spruce ecosystem.

Important Classes

AbstractCalendarEventViewController - The class that calendar events extend to customize how they render in the calendar.
MethodReturnsDescription
CalendarEventViewControllerControllerA view controller for calendar events that extends AbstractCalendarEventViewController and implements CalendarEventVc.
AbstractSkillViewController - The class your Skill Views can extend to have access to helpful properties.
MethodReturnsDescription
render()stringRenders the view and returns the string ‘go-team’.
BookSkillViewController (constructor)BookSkillViewControllerInitializes a new instance of BookSkillViewController by extending AbstractSkillViewController.
AbstractViewController - The class your views can extend to have access to helpful properties.
MethodReturnsDescription
CardViewController (constructor)CardViewController<V>Initializes a new instance of CardViewController by extending AbstractViewController<V> and implementing ViewController<V>.
ActiveRecordCardViewController - A card that holds an `ActiveRecordList` to make loading, searching, and paging through database records a breeze.

Storybook

MethodReturnsDescription
activeRecordCardActiveRecordCardViewControllerProvides the active record card view controller.
'active-record-card'ActiveRecordCardViewControllerMaps the active record card route to the ActiveRecordCardViewController.
ActiveRecordListViewController - A list that makes loading, searching, and paging through database record a breeze.

Storybook

MethodReturnsDescription
activeRecordListActiveRecordListViewControllerProvides the active record list view controller.
'active-record-list'ActiveRecordListViewControllerMaps the active record list route to the ActiveRecordListViewController.
AutocompleteInputViewController - Turns a text input into an autocomplete input.
MethodReturnsDescription
autocompleteInputAutocompleteInputViewControllerProvides the autocomplete input view controller.
'autocomplete-input'AutocompleteInputViewControllerMaps the autocomplete input route to the AutocompleteInputViewController.
BigFormViewController - A form that renders one field at a time with customizable transitions between questions.

Storybook.

MethodReturnsDescription
bigFormBigFormViewControllerProvides the big form view controller.
'big-form'BigFormViewControllerMaps the big form route to the BigFormViewController.
ButtonBarViewController - A strip of buttons that supports selection and deselection.

Storybook.

MethodReturnsDescription
buttonBarButtonBarViewControllerProvides the button bar view controller.
'button-bar'ButtonBarViewControllerMaps the button bar route to the ButtonBarViewController.
ButtonGroupViewController - An array of buttons that supports selected and deselected states.

Storybook.

MethodReturnsDescription
buttonsButtonGroupButton[]Retrieves the array of button group buttons.
selectedButtonIdsstring[]Retrieves the array of selected button IDs.
selectionChangeHandlerSelectionChangeHandlerRetrieves the selection change handler function, if any.
CalendarViewController - A calendar that supports day and month views (more soon)!

Storybook.

MethodReturnsDescription
modelOmit<CalendarOptions, 'events'>The calendar options model excluding the events property.
vcIdsByEventTypeRecord<string, string>A record mapping event types to view controller IDs.
vcsByIdRecord<string, CalendarEventViewController>A record mapping view controller IDs to their corresponding calendar event view controllers.
CalendarEventViewController - The view controller that renders for each event by detail!

Storybook.

MethodReturnsDescription
AbstractCalendarEventViewController (constructor)AbstractCalendarEventViewControllerInitializes a new instance of AbstractCalendarEventViewController by extending AbstractViewController<Event> and implementing CalendarEventVc.
CardViewController - The building block of every Skill View!

Storybook.

MethodReturnsDescription
FeedbackCardViewController (constructor)FeedbackCardViewControllerInitializes a new instance of FeedbackCardViewController.
FamilyMemberFormCardViewController (constructor)FamilyMemberFormCardViewControllerInitializes a new instance of FamilyMemberFormCardViewController.
'eightbitstories.feedback-card'FeedbackCardViewControllerMaps the feedback card route to the FeedbackCardViewController.
'eightbitstories.family-member-form-card'FamilyMemberFormCardViewControllerMaps the family member form card route to the FamilyMemberFormCardViewController.
'eightbitstories.feedback-card' (ConstructorParameters)ConstructorParameters<typeof FeedbackCardViewController>[0]Provides the constructor parameters for FeedbackCardViewController.
'eightbitstories.family-member-form-card' (ConstructorParameters)ConstructorParameters<typeof FamilyMemberFormCardViewController>[0]Provides the constructor parameters for FamilyMemberFormCardViewController.
CountdownTimerViewController - A flipboard style countdown timer!

Storybook.

MethodReturnsDescription
CountdownTimerViewController (constructor)CountdownTimerViewControllerInitializes a new instance of CountdownTimerViewController.
CountdownTimerViewControllerOptionsCountdownTimerViewControllerOptionsOptions for configuring a CountdownTimerViewController.
navigationNavigationViewControllerProvides the navigation view controller.
'countdown-timer'CountdownTimerViewControllerMaps the countdown timer route to the CountdownTimerViewController.
'progress-navigator'ProgressNavigatorViewControllerMaps the progress navigator route to the ProgressNavigatorViewController.
FeedViewController - A chat component for handling coversations!

Storybook.

MethodReturnsDescription
FeedViewController (constructor)FeedViewControllerInitializes a new instance of FeedViewController.
FeedViewControllerOptionsFeedViewControllerOptionsOptions for configuring a FeedViewController.
mapMapViewControllerProvides the map view controller.
feedFeedViewControllerProvides the feed view controller.
navigationNavigationViewControllerProvides the navigation view controller.
FormBuilderViewController - A component for building custom forms!

Storybook.

FormViewController - A form comprised of inputs (or a list with inputs)!

Storybook.

ListCellViewController - The cells that build a row.

Storybook.

MethodReturnsDescription
CellVc(index: number)ListCellViewControllerRetrieves the list cell view controller at the specified index.
assert.isTrue(value: boolean)voidAsserts that the provided value is true.
ListRowViewController - Rows that build a list.

Storybook.

MethodReturnsDescription
rowVcListRowViewControllerThe list row view controller instance associated with the cell input key down event.
keyKeyboardKeyThe keyboard key that was pressed during the cell input key down event.
ListViewController - A List based on rows and cells.

Storybook.

MethodReturnsDescription
'form-builder-card'FormBuilderCardViewControllerMaps the form builder card route to the FormBuilderCardViewController.
listListViewControllerProvides the list view controller.
toolBeltToolBeltViewControllerProvides the tool belt view controller.
MapViewController - A customizable map with pin and navigation support.

Storybook.

MethodReturnsDescription
MapViewController (constructor)MapViewControllerInitializes a new instance of MapViewController.
MapViewControllerOptionsMapViewControllerOptionsOptions for configuring a MapViewController.
LoginViewController (constructor)LoginViewControllerInitializes a new instance of LoginViewController.
NavigationViewController - Customize the navigation. Currently render inside the ControlBar.

Storybook.

MethodReturnsDescription
feedFeedViewControllerProvides the feed view controller.
navigationNavigationViewControllerProvides the navigation view controller.
'countdown-timer'CountdownTimerViewControllerMaps the countdown timer route to the CountdownTimerViewController.
PolarAreaViewController - A polar area chart.

Storybook.

MethodReturnsDescription
modelPolarAreaRetrieves the model for the Polar Area view controller.
PolarAreaViewController (constructor)PolarAreaViewControllerInitializes a new instance of PolarAreaViewController by extending AbstractViewController<PolarArea>.
ProgressNavigatorViewController - Renders at the top of the screen to track progress through any process.

Storybook.

MethodReturnsDescription
progressNavigatorProgressNavigatorViewControllerProvides the progress navigator view controller.
WithProgressSkillView (constructor)WithProgressSkillViewInitializes a new instance of WithProgressSkillView with the provided view controller options.
ProgressViewController - A progress indicator with an optional message.

Storybook.

MethodReturnsDescription
statsStatsViewControllerProvides the stats view controller.
progressProgressViewControllerProvides the progress view controller.
ratingsRatingsViewControllerProvides the ratings view controller.
RatingsViewController - A ratings component to gauge sentiment or against a scale.

Storybook.

MethodReturnsDescription
RatingsInputComponentIconRatingsInputComponentIconRetrieves the icon component used in the ratings input.
RatingsViewControllerOptionsRatingsViewControllerOptionsOptions for configuring a RatingsViewController.
ControllingARatingsViewTest (constructor)ControllingARatingsViewTestInitializes a new instance of ControllingARatingsViewTest by extending AbstractViewControllerTest.
vcRatingsViewControllerThe static instance of RatingsViewController used in the test.
SwipeViewController - A version of a card where sections are rendered as a swipe view.

Storybook.

MethodReturnsDescription
Vc(options: SwipeViewControllerOptions)SwipeCardViewControllerCreates and returns a new instance of SwipeCardViewController with the provided options.
StatsViewController - Render numbers with labels with some nice animations.

Storybook.

MethodReturnsDescription
'active-record-list'ActiveRecordListViewControllerMaps the active record list route to the ActiveRecordListViewController.
statsStatsViewControllerProvides the stats view controller.
progressProgressViewControllerProvides the progress view controller.
TalkingSprucebotViewController - Sprucebot animation with typing text. Great for storytelling!

Storybook.

MethodReturnsDescription
talkingSprucebotTalkingSprucebotViewControllerProvides the talking Sprucebot view controller.
'talking-sprucebot'TalkingSprucebotViewControllerMaps the talking Sprucebot route to the TalkingSprucebotViewController.
ToolBeltViewController - Holds an extra set of cards that hide when not in use!

Storybook.

MethodReturnsDescription
listListViewControllerProvides the list view controller.
toolBeltToolBeltViewControllerProvides the tool belt view controller.

Skill View Lifecycle

Root Skill View

Coming soon…

Rendering Skill Views

Skill Views are the equivalent of pages in a “standard” web application. They are accessible via the url in 2 ways.

  1. Subdomain: https://{skillNamespace}.spruce.bot (will render your RootSkillViewController)
  2. Hash: https://spruce.bot/#/views/{skillNamespace}.{viewId}

Root Skill View

Let’s get started on rendering a RootSkillView.

Test 1: Load Your (Root) Skill View

We’ll start with the RootSkillViewController. All you have to do to start is try and load your Skill View and the test will fail.

import {
    AbstractSpruceFixtureTest
} from '@sprucelabs/spruce-test-fixtures'

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async canLoadRootSkillView() {
        this.views.Controller('eightbitstories.root', {}),
    }
}
Production 1: Create Your Root Skill View

This part is pretty easy! Run this following command and follow the instructions!

spruce create.view

Rendering A Different Skill View

Coming soon…

Redirecting Between Skill Views

Coming soon…

Rendering Cards

Rendering Card by Id

Now that you have your RootSkillViewController, you can start adding cards to it. Here is how you can test and implement some cards.

Test 1: Assert card is rendered
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'   

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersExpectedCard() {
        const vc = this.views.Controller('eightbitstories.root', {})
        vcAssert.assertSkillViewRendersCard(vc, 'my-card')
    }
}
Production 1a: Render your card

We’ll quickly create a CardViewController to render in our RootSkillViewController as the only card in a single layout.

import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
    CardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'
    protected cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.Controller('card', {
            id: 'my-card',
            header: {
                title: 'My Card',
            },
        })
    }

    public render(): SkillView {
        return {
            layouts: [
                {
                    cards: [this.cardVc.render()],
                },
            ],
        }
    }
}

Note: Your card’s ViewModel is never fully tested. Things like header text changes too much to make a meaningful test. The only time you should test the copy in your view is if it’s dynamically generated.

Production 1b: Cleanup your SkillView

Extracting the construction of your nested view controllers to builder methods makes the constructor of your RootSkillViewController much easier to read and makes refactor easier.

import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
    CardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'
    protected cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.CardVc()
    }

    private CardVc() {
        return this.Controller('card', {
            id: 'my-card',
            header: {
                title: 'My Card',
            },
        })
    }

    public render(): SkillView {
        return {
            layouts: [
                {
                    cards: [this.cardVc.render()],
                },
            ],
        }
    }
}

Rendering your own ViewController Class

Test 1: Assert card rendered of type

This test picks up where the last one left off. We’re going to test that the CardViewController is rendered as an instance of MyCardViewController.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'   

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersExpectedCard() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const cardVc = vcAssert.assertSkillViewRendersCard(vc, 'my-card')
        vcAssert.assertControllerInstanceOf(cardVc, MyCardViewController)
    }
}

Note: You will be getting a “MyCardViewController not defined” error here, that is expected. We will fix that in the next step.

Production 1: Create your own ViewController Class
spruce create.view

Make sure you select “Card” as the type of ViewModel you want your new ViewController to render and name it “My Card”.

Note: View controllers will automatically have ‘ViewController’ appended to the end of the name you provide, so “My Card” will become “MyCardViewController”.

Test 2: Import ViewController Class
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'  
import MyCardViewController from '../../ViewControllers/MyCard.vc' 

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersExpectedCard() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const cardVc = vcAssert.assertSkillViewRendersCard(vc, 'my-card')
        vcAssert.assertControllerInstanceOf(cardVc, MyCardViewController)
    }
}
Production 1a: Implement your ViewController Class

In Spruce, we use composition over inheritance. That means your MyCardViewController should have a CardViewController as a property and render that, rather than trying to extend CardViewController or implement the CardViewController interface.

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        
        this.cardVc = this.Controller('card', {
            id: 'my-card',
            header: {
                title: "Hey there!",
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}
Production 1b: Update your RootSkillViewController

Now it’s just a matter of swapping out card for my-card in your CardVc builder method, renaming a few things, and updating the render method to render your new MyCardViewController.

import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
    CardViewController,
} from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../ViewControllers/MyCard.vc'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'
    protected myCardVc: MyCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.myCardVc = this.MyCardVc()
    }

    private MyCardVc() {
        return this.Controller('eightbitstories.my-card', {})
    }

    public render(): SkillView {
        return {
            layouts: [
                {
                    cards: [this.myCardVc.render()],
                },
            ],
        }
    }
}

Production 1c: Cleanup MyCardViewController

Now we’ll go throught the usual refactor of extracting the construction of your view controllers to builder methods.

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.CardVc()
    }

    private CardVc() {
        return this.Controller('card', {
            id: 'my-card',
            header: {
                title: "Hey there!",
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

Rendering remote cards

The interoperatibility of Spruce allows you to render cards from other skills. It’s a great way to share views accross multiple skills. To pull this off, you’ll leverage the RemoteViewControllerFactory provided by @sprucelabs/spruce-heartwood-utils.

Test 1a: Set stage for importing RemoteViewControllerFactory

For this first test, we’re going to drop in the MockRemoteViewControllerFactory test double to get ready to make some assertions.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'   

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    @test()
    protected static async loadsRemoteCard() {
        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
    }
}
Test 1b: Import @sprucelabs/spruce-heartwood-utils

You should get 2 errors, one for each class you need to import. Lets start by adding the correct dependency using yarn.

yarn add @sprucelabs/spruce-heartwood-utils

Now that this is done, you can import the classes you need and the tests will pass.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'   
import { RemoteViewControllerFactoryImpl, MockRemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    @test()
    protected static async loadsRemoteCard() {
        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
    }
}
Test 1c: Assert card is fetched

Now I’m going to execute the operation (in this case this.views.load(...)) where I expect the remote card to be fetched. Then I’ll assert that it was fetched by accessing the MockRemoteViewControllerFactory instance.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'   
import { RemoteViewControllerFactoryImpl, MockRemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    @test()
    protected static async loadsRemoteCard() {
        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory


        const vc = this.views.Controller('eightbitstories.root', {})
        await this.views.load(vc)

        MockRemoteViewControllerFactory.getInstance().assertFetchedRemoteController('other-skill.my-card')

    }
}
Production 1: Load the remote card

The first step in production is to load the remote card. I won’t be actually rendering it yet, that’ll be a different test!

import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    public async load() {
        const remote = RemoteViewControllerFactoryImpl.Factory({
            connectToApi: this.connectToApi,
            vcFactory: this.getVcFactory()
        })

        await remote.RemoteController('other-skill.my-card', {})
    }

     public render() {
        return {}
    }
}
Test 2: Drop in the remote card to the MockRemoteViewControllerFactory

You should now be getting an error something like “Couldn’t find a view controller called “other-skill.my-card”.” I’m gonna drop in a CardViewController into the MockRemoteViewControllerFactory to make that error go away.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert, CardViewControllerImpl } from '@sprucelabs/heartwood-view-controllers'   
import { RemoteViewControllerFactoryImpl, MockRemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    @test()
    protected static async loadsRemoteCard() {
        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory

        MockRemoteViewControllerFactory.dropInRemoteController(
            'forms.remote-form-card',
            CardViewControllerImpl
        )

        const vc = this.views.Controller('eightbitstories.root', {})
        await this.views.load(vc)

        MockRemoteViewControllerFactory.getInstance().assertFetchedRemoteController('other-skill.my-card')

    }
}

Note: I dropped in a CardViewControllerImpl to keep the example simple, but you will probably want to drop in a test double to make more assertions later.

Test 3: Assert Skill View renders the remote card
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert, CardViewControllerImpl } from '@sprucelabs/heartwood-view-controllers'   
import { RemoteViewControllerFactoryImpl, MockRemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    @test()
    protected static async loadsRemoteCard() {
        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory

        MockRemoteViewControllerFactory.dropInRemoteController(
            'forms.remote-form-card',
            CardViewControllerImpl
        )

        const vc = this.views.Controller('eightbitstories.root', {})
        await this.views.load(vc)

        MockRemoteViewControllerFactory.getInstance().assertFetchedRemoteController('other-skill.my-card')

    }

    @test()
    protected static async rendersRemoteCard() {
        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory

        MockRemoteViewControllerFactory.dropInRemoteController(
            'forms.remote-form-card',
            CardViewControllerImpl
        )

        const vc = this.views.Controller('eightbitstories.root', {})

        await this.views.load(vc)

        MockRemoteViewControllerFactory.getInstance().assertSkillViewRendersRemoteCard(vc, 'other-skill.my-card')
    }
}
Production 2: Render the remote card
import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    private remoteCardVc?: CardViewController

    public async load() {
        const remote = RemoteViewControllerFactoryImpl.Factory({
            connectToApi: this.connectToApi,
            vcFactory: this.getVcFactory()
        })

        this.remoteCardVc = await remote.RemoteController('other-skill.my-card', {})
    }

    public render() {
        if (!this.remoteCardVc) {
            return {}
        }

        return {
            layouts: [
                {
                    cards: [
                        this.remoteCardVc.render()
                    ]
                }
            ]
        }
    }
}

Note: In order to get types to pass, I had to optionally return early from render(). Because render() is called before load(), I had to make sure that this.remoteCardVc was optional and that I returned an empty object if it was not set. You can return anything you want before load.

Test 4: Dry the test
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert, CardViewControllerImpl } from '@sprucelabs/heartwood-view-controllers'   
import { RemoteViewControllerFactoryImpl, MockRemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    private static vc: RootSkillViewController

    protected static async beforeEach() {
        await super.beforeEach()

        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
        MockRemoteViewControllerFactory.dropInRemoteController(
            'forms.remote-form-card',
            CardViewControllerImpl
        )
        this.vc = this.views.Controller('eightbitstories.root', {})
    }

    @test()
    protected static async loadsRemoteCard() {
        await this.load()
        this.mockFactory.assertFetchedRemoteController('other-skill.my-card')
    }

    @test()
    protected static async rendersRemoteCard() {
        await this.load()
        this.mockFactory.assertSkillViewRendersRemoteCard(vc, 'other-skill.my-card')
    }

    public static async load() {
        await this.views.load(vc)
    }

    public static get mockFactory() {
        return MockRemoteViewControllerFactory.getInstance()
    }
}
Test 5: Assert render is triggered

Simply setting the remote card to this.remoteCardVc will not cause the card to be rendered. You need to manually trigger a render on your SkillViewController.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert, CardViewControllerImpl } from '@sprucelabs/heartwood-view-controllers'   
import { RemoteViewControllerFactoryImpl, MockRemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'

export default class RenderingARemoteCard extends AbstractSpruceFixtureTest {
    private static vc: RootSkillViewController

    protected static async beforeEach() {
        await super.beforeEach()

        RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
        MockRemoteViewControllerFactory.dropInRemoteController(
            'forms.remote-form-card',
            CardViewControllerImpl
        )
        this.vc = this.views.Controller('eightbitstories.root', {})
    }

    @test()
    protected static async loadsRemoteCard() {
        await this.load()
        this.mockFactory.assertFetchedRemoteController('other-skill.my-card')
    }

    @test()
    protected static async rendersRemoteCard() {
        await this.load()
        this.mockFactory.assertSkillViewRendersRemoteCard(vc, 'other-skill.my-card')
    }

    @test()
    protected static async triggersRender() {
        await this.load()
        vcAssert.assertTriggerRenderCount(this.vc, 1)
    }

    public static async load() {
        await this.views.load(vc)
    }

    public static get mockFactory() {
        return MockRemoteViewControllerFactory.getInstance()
    }
}
Production 3: Trigger render in Skill View
import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    private remoteCardVc?: CardViewController

    public async load() {
        const remote = RemoteViewControllerFactoryImpl.Factory({
            connectToApi: this.connectToApi,
            vcFactory: this.getVcFactory()
        })

        this.remoteCardVc = await remote.RemoteController('other-skill.my-card', {})
        this.triggerRender()
    }

    public render() {
        if (!this.remoteCardVc) {
            return {}
        }

        return {
            layouts: [
                {
                    cards: [
                        this.remoteCardVc.render()
                    ]
                }
            ]
        }
    }
}

Active Record Card

The ActiveRecordCard is a special card that is used to quickly render records returned from a listener (usually pulled from a database, but not necessarily). Generally speaking, it is a great way to render a list of records.

Note: This test starts with a MyCardViewContoller that you have already created in the “Rendering Card by Id” section. You should start with that test before continuing.

Test 1: Assert card is rendered as instance of ActiveRecordCardViewController

If you haven’t already created a test, you need to run:

spruce create.test

And call it “My Card” and select AbstractSpruceFixtureTest as the base test class (unless you have a different base class you want to use). The idea here is to test the card independently of the Skill View.

import { vcAssert, AbstractSpruceFixtureTest } from '@sprucelabs/heartwood-view-controllers'
import { test } from '@sprucelabs/test-utils'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        vcAssert.assertIsActiveRecordCard(vc)
    }
}

Production 1: Render the ActiveRecordCard
import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    buildActiveRecordCard,
    ActiveRecordCardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private activeRecordCardVc: ActiveRecordCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.activeRecordCardVc = this.ActiveCardVc()
    }

    private ActiveCardVc() {
        return this.Controller(
            'active-record-card',
            buildActiveRecordCard({
                id: 'my-cards-id',
                header: {
                    title: "Family Members",
                },
                eventName: 'list-installed-skills::v2020_12_25',
                responseKey: 'skills',
                rowTransformer: () => ({
                    id: 'aoeu',
                    cells: [],
                }),
            })
        )
    }

    public render() {
        return this.activeRecordCardVc.render()
    }
}

Note: The eventName and responseKey are placeholders. You will need to replace them with the actual event name and response key that you are listening for in upcoming tests.

Test 2: Assert ActiveRecordCard is emitting the correct event on load

Even though our goal is to make sure that the ActiveRecordCard is emitting the correct event on load, we’ll first need to make sure that MyCardViewController has a method called load that calls this.activeRecordCardVc.load(). We’re not going to jump right there, though. We’ll start with the test below which will fail because MyCardViewController doesn’t have a load method.

import { vcAssert, AbstractSpruceFixtureTest } from '@sprucelabs/heartwood-view-controllers'
import { eventFaker } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        vcAssert.assertIsActiveRecordCard(vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        let wasHit = false

        await eventFaker.on('eightbitstories.list-family-members::v2024_07_22', () => {
            wasHit = true
            return {
                familyMembers: [],
            }
        })

        const vc = this.views.Controller('eightbitstories.my-card', {})
        await vc.load()
    }
}

Note: The event eightbitstories.list-family-members::v2024_07_22 is a best guess at what the event name will be. It will show a type error to start, that is fine, we’ll fix it in a moment.

Note: The response to the event, { familyMembers: [] }, is what we’d like the event to respond with, once we design it. The idea being, design it how you think it should work, and then make it work that way.

Production 2: Stub the load method to MyCardViewController
import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    buildActiveRecordCard,
    ActiveRecordCardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private activeRecordCardVc: ActiveRecordCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.activeRecordCardVc = this.ActiveCardVc()
    }

    private ActiveCardVc() {
        return this.Controller(
            'active-record-card',
            buildActiveRecordCard({
                id: 'my-cards-id',
                header: {
                    title: "Family Members",
                },
                eventName: 'list-installed-skills::v2020_12_25',
                responseKey: 'skills',
                rowTransformer: () => ({
                    id: 'aoeu',
                    cells: [],
                }),
            })
        )
    }

    public async load() {}

    public render() {
        return this.activeRecordCardVc.render()
    }
}

Test 3: Assert the event is being emitted
import { vcAssert, AbstractSpruceFixtureTest } from '@sprucelabs/heartwood-view-controllers'
import { eventFaker } from '@sprucelabs/spruce-test-fixtures'
import { test, assert } from '@sprucelabs/test-utils'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        vcAssert.assertIsActiveRecordCard(vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        let wasHit = false

        await eventFaker.on('eightbitstories.list-family-members::v2024_07_22', () => {
            wasHit = true
            return {
                familyMembers: [],
            }
        })

        const vc = this.views.Controller('eightbitstories.my-card', {})
        await vc.load()

        assert.isTrue(wasHit, 'The event eightbitstories.list-family-members::v2024_07_22 was not emitted.')
    }
}
Production 3a: Emit the event on load

Not only are we going to load the ActiveRecordCard on load, but we’re going to update the event eightbitstories.list-family-members::v2024_07_22 and the responseKey to match what we want it to look like.

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    buildActiveRecordCard,
    ActiveRecordCardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private activeRecordCardVc: ActiveRecordCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.activeRecordCardVc = this.ActiveCardVc()
    }

    private ActiveCardVc() {
        return this.Controller(
            'active-record-card',
            buildActiveRecordCard({
                id: 'my-cards-id',
                header: {
                    title: "Family Members",
                },
                eventName: 'eightbitstories.list-family-members::v2024_07_22',
                responseKey: 'familyMembers',
                rowTransformer: () => ({
                    id: 'aoeu',
                    cells: [],
                }),
            })
        )
    }

    public async load() {
        await this.activeRecordCardVc.load()
    }

    public render() {
        return this.activeRecordCardVc.render()
    }
}

Production 3b: Create the event

You should be getting an error that the event eightbitstories.list-family-members::v2024_07_22 doesn’t exist. Let’s create it!

spruce create.event

Make sure to name it “List Family Members”.

Note: Now you can jump into the event definition files and design it how you think it should work. Once you have that, you can run spruce sync.events to generate the event contracts and your test will pass.

Production 3c (Optional): Stub a target

If your event requires a target, let’s stub one in for now:

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    buildActiveRecordCard,
    ActiveRecordCardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private activeRecordCardVc: ActiveRecordCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.activeRecordCardVc = this.ActiveCardVc()
    }

    private ActiveCardVc() {
        return this.Controller(
            'active-record-card',
            buildActiveRecordCard({
                id: 'my-cards-id',
                header: {
                    title: "Family Members",
                },
                eventName: 'eightbitstories.list-family-members::v2024_07_22',
                responseKey: 'familyMembers',
                target: {
                    organizationId: 'aoeu'
                },
                rowTransformer: () => ({
                    id: 'aoeu',
                    cells: [],
                }),
            })
        )
    }

    public async load() {
        await this.activeRecordCardVc.load()
    }

    public render() {
        return this.activeRecordCardVc.render()
    }
}

Test 4a: Dry the tests

Now is as good as time as any to cleaup our test code. We’ll move the construction of the MyCardViewController the beforeEach() of the test to make the test easier to read and refactor later.

import { vcAssert, AbstractSpruceFixtureTest } from '@sprucelabs/heartwood-view-controllers'
import { eventFaker } from '@sprucelabs/spruce-test-fixtures'
import { test, assert } from '@sprucelabs/test-utils'
import MyCardViewController from '../../ViewControllers/MyCard.vc'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    protected static vc: MyCardViewController

    protected static async beforeEach() {
        await super.beforeEach()
        this.vc = this.views.Controller('eightbitstories.my-card', {})
    }

    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        vcAssert.assertIsActiveRecordCard(this.vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        let wasHit = false

        await eventFaker.on('eightbitstories.list-family-members::v2024_07_22', () => {
            wasHit = true
            return {
                familyMembers: [],
            }
        })

        await this.vc.load()

        assert.isTrue(wasHit, 'The event eightbitstories.list-family-members::v2024_07_22 was not emitted.')
    }
}
Test 4b (Optional): Assert the correct target

If this were a SkillViewController, our load() method would be passed Scope, which would be how we could get the current Organization or Location. But, since this is a ViewController, we don’t have that luxury. We’ll need the person calling load() on our ViewController to pass the important information in and we’ll construct the target from that.

import { SpruceSchemas, vcAssert } from '@sprucelabs/heartwood-view-controllers'
import { eventFaker, AbstractFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { assert, generateId, test } from '@sprucelabs/test-utils'
import MyCardViewController from '../../../viewControllers/MyCard.vc'

export default class MyCardTest extends AbstractFixtureTest {
    private static vc: MyCardViewController

    protected static async beforeEach() {
        await super.beforeEach()
        this.vc = this.views.Controller('eightbitstories.my-card', {})
    }

    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        vcAssert.assertIsActiveRecordCard(this.vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        const organizationId = generateId()

        let wasHit = false
        let passedTarget:
            | SpruceSchemas.Eightbitstories.v2024_07_22.ListFamilyMembersEmitTarget
            | undefined

        await eventFaker.on(
            'eightbitstories.list-family-members::v2024_07_22',
            ({ target }) => {
                passedTarget = target
                wasHit = true
                return {
                    people: [],
                }
            }
        )

        await this.vc.load(organizationId)

        assert.isTrue(wasHit)
        assert.isEqualDeep(passedTarget, { organizationId })
    }
}

Production 4 (Optional): Set the target on the ActiveRecordCard
import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    buildActiveRecordCard,
    ActiveRecordCardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private activeRecordCardVc: ActiveRecordCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.activeRecordCardVc = this.ActiveCardVc()
    }

    private ActiveCardVc() {
        return this.Controller(
            'active-record-card',
            buildActiveRecordCard({
                id: 'my-cards-id',
                header: {
                    title: "Family Members",
                },
                eventName: 'eightbitstories.list-family-members::v2024_07_22',
                responseKey: 'familyMembers',
                rowTransformer: () => ({
                    id: 'aoeu',
                    cells: [],
                }),
            })
        )
    }

    public async load(organizationId: string) {
        this.activeRecordCardVc.setTarget({ organizationId })
        await this.activeRecordCardVc.load()
    }

    public render() {
        return this.activeRecordCardVc.render()
    }
}

Note: Because we are setting the target using setTarget(), we don’t need to pass it to the constructor of the ActiveRecordCard. So we removed the stubbed target from the constructor.

Test 5: Refactor the test

The idea here is to remove the redundant assertions and to extract out the eventFaker to an EventFaker class we can reuse across tests.

import { vcAssert } from '@sprucelabs/heartwood-view-controllers'
import { AbstractFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { assert, generateId, test } from '@sprucelabs/test-utils'
import MyCardViewController from '../../../viewControllers/MyCard.vc'
import EventFaker, { ListFamilyMembersTargetAndPayload } from '../../support/EventFaker'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    private static vc: MyCardViewController
    private static eventFaker: EventFaker

    protected static async beforeEach() {
        await super.beforeEach()
        this.eventFaker = new EventFaker()
        this.vc = this.views.Controller('eightbitstories.my-card', {})
    }

    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        vcAssert.assertIsActiveRecordCard(this.vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        const organizationId = generateId()

        let passedTarget:
            | ListFamilyMembersTargetAndPayload['target']
            | undefined

        await this.eventFaker.fakeListFamilyMembers(({ target }) => {
            passedTarget = target
            return {
                people: []
            }
        })

        await this.vc.load(organizationId)

        assert.isEqualDeep(passedTarget, { organizationId })
    }
}

And our new EventFaker implementation:

import { eventFaker, SpruceSchemas } from '@sprucelabs/spruce-test-fixtures'
import { generateId } from '@sprucelabs/test-utils'
import { SpruceSchemas, Person } from '@sprucelabs/spruce-core-schemas'

export class EventFaker {
    public async fakeListFamilyMembers(
        cb?: (targetAndPayload: ListConnectPeopleTargetAndPayload) => void | ListConnectedPeopleResponse
    ) {
        await eventFaker.on(
            'eightbitstories.list-familyMembers::v2024_07_22',
            (targetAndPayload) => {
                return cb?.(targetAndPayload) ?? {
                    people: [],
                }
            }
        )
    }
}

export type ListFamilyMembersTargetAndPayload =
    SpruceSchemas.Eightbitstories.v2024_07_22.ListFamilyMembersEmitTargetAndPayload
export type ListConnectedPeopleResponse =
    SpruceSchemas.Eightbitstories.v2024_07_22.ListFamilyMembersResponsePayload

Test 6a: Assert the expected rows are rendered
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'
import { AbstractFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { assert, generateId, test } from '@sprucelabs/test-utils'
import MyCardViewController from '../../../viewControllers/MyCard.vc'
import { Person } from '@sprucelabs/spruce-core-schemas'
import EventFaker, { ListFamilyMembersTargetAndPayload } from '../../support/EventFaker'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    private static vc: MyCardViewController
    private static eventFaker: EventFaker

    protected static async beforeEach() {
        await super.beforeEach()
        this.eventFaker = new EventFaker()
        this.vc = this.views.Controller('eightbitstories.my-card', {})
    }

    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        vcAssert.assertIsActiveRecordCard(this.vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        const organizationId = generateId()

        let passedTarget:
            | ListFamilyMembersTargetAndPayload['target']
            | undefined

        await this.eventFaker.fakeListFamilyMembers(({ target }) => {
            passedTarget = target
            return {
                people: []
            }
        })

        await this.vc.load(organizationId)

        assert.isEqualDeep(passedTarget, { organizationId })
    }

    @test()
    protected static async rendersRowForResults() {
        const organizationId = generateId()

        const person: Person = {
            id: generateId(),
            casualName: generateId(),
            networkInterface: 'eth0',
        }

        await this.eventFaker.fakeListConnectedPeople(() => [person])

        await this.vc.load(organizationId)

        listAssert.listRendersRow(this.vc.getListVc(), person.id)
    }
}

Note: You should get an error that getListVc() doesn’t exist. To fix this, we’ll need a Spy test double to expose the ActiveRecordCard’s getListVc() method.

Test 6b: Create the Test Double

Here we’re going to create the SpyMyCard test double, override the controller using this.views.setController(), and typecast the controller to SpyMyCard to expose the getListVc() method.

import { vcAssert } from '@sprucelabs/heartwood-view-controllers'
import { AbstractFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { assert, generateId, test } from '@sprucelabs/test-utils'
import MyCardViewController from '../../../viewControllers/MyCard.vc'
import { Person } from '@sprucelabs/spruce-core-schemas'
import EventFaker, { ListFamilyMembersTargetAndPayload } from '../../support/EventFaker'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard
    private static eventFaker: EventFaker

    protected static async beforeEach() {
        await super.beforeEach()
        this.eventFaker = new EventFaker()

        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
    }

    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        vcAssert.assertIsActiveRecordCard(this.vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        const organizationId = generateId()

        let passedTarget:
            | ListFamilyMembersTargetAndPayload['target']
            | undefined

        await this.eventFaker.fakeListFamilyMembers(({ target }) => {
            passedTarget = target
            return {
                people: []
            }
        })

        await this.vc.load(organizationId)

        assert.isEqualDeep(passedTarget, { organizationId })
    }

    @test()
    protected static async rendersRowForResults() {
        const organizationId = generateId()

        const person: Person = {
            id: generateId(),
            casualName: generateId(),
            networkInterface: 'eth0',
        }

        await this.eventFaker.fakeListConnectedPeople(() => [person])

        await this.vc.load(organizationId)

        listAssert.listRendersRow(this.vc.getListVc(), person.id)
    }
}

class SpyMyCard extends MyCardViewController {
    public getListVc() {
        return this.activeRecordCardVc.getListVc()
    }
}

Note: Even though the test will pass, you’ll get a type error because activeRecordCard is private in MyCardViewController. We’ll address that in the production code while we make the test pass.

Production 6: Render the row as expected

We are doing 2 things here:

  1. Setting activeRecordCardVc to protected so that SpyMyCard can access it.
  2. Updating the rowTransformer to use the family member’s id for the row id and the family member’s name for the row cell.
import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    buildActiveRecordCard,
    ActiveRecordCardViewController,
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    protected activeRecordCardVc: ActiveRecordCardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.activeRecordCardVc = this.ActiveCardVc()
    }

    private ActiveCardVc() {
        return this.Controller(
            'active-record-card',
            buildActiveRecordCard({
                id: 'my-cards-id',
                header: {
                    title: "Family Members",
                },
                eventName: 'eightbitstories.list-family-members::v2024_07_22',
                responseKey: 'familyMembers',
                rowTransformer: (familyMember) => ({
                    id: familyMember.id,
                    cells: [{
                        text: {
                            content: familyMember.name
                        }
                    }],
                }),
            })
        )
    }

    public async load(organizationId: string) {
        this.activeRecordCardVc.setTarget({ organizationId })
        await this.activeRecordCardVc.load()
    }

    public render() {
        return this.activeRecordCardVc.render()
    }
}

Test 7: Dry the test

There is quite a bit happening here:

  1. Moved a lot of things to the beforeEach() to simplify the tests
    1. The organizationId
    2. The familyMembers return from the event
    3. The lastListFamilyMembersTarget from the event
  2. Created a load() method to pass the organizationId to load() for us
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'
import { AbstractFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { assert, generateId, test } from '@sprucelabs/test-utils'
import MyCardViewController from '../../../viewControllers/MyCard.vc'
import { Person } from '@sprucelabs/spruce-core-schemas'
import EventFaker, { ListFamilyMembersTargetAndPayload } from '../../support/EventFaker'

export default class WhosOnWifiCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard
    private static eventFaker: EventFaker
    private static organizationId: string
    private static lastListFamilyMembersTarget:
        | ListFamilyMembersTargetAndPayload['target']
        | undefined

    private static familyMembers: Person[] = []

    protected static async beforeEach() {
        await super.beforeEach()
        
        this.eventFaker = new EventFaker()
        this.organizationId = generateId()
        this.familyMembers = []

        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard

        delete this.lastListFamilyMembersTarget
        
        await this.eventFaker.fakeListFamilyMembers(({ target }) => {
            this.lastListFamilyMembersTarget = target
            return {
                people: this.familyMembers
            }
        })
    }

    @test()
    protected static async rendersAsInstanceOfActiveRecordCard() {
        vcAssert.assertIsActiveRecordCard(this.vc)
    }

    @test()
    protected static async emitsListConnectedPeopleOnLoad() {
        await this.load()
        assert.isEqualDeep(this.lastListFamilyMembersTarget, { organizationId: this.organizationId })
    }

    @test()
    protected static async rendersRowForResults() {
        this.familyMembers.push({
            id: generateId(),
            casualName: generateId(),
            networkInterface: 'eth0',
        })

        await this.load()

        listAssert.listRendersRow(this.vc.getListVc(), this.familyMembers[0].id)
    }

    protected static async load() {
        await this.vc.load(this.organizationId)
    }
}

class SpyMyCard extends MyCardViewController {
    public getListVc() {
        return this.activeRecordCardVc.getListVc()
    }
}

Rendering Dialogs

Dialogs are cards rendered modally. You can render a basic Card ViewModel or you can render a CardViewController as a dialog.

Rendering a simple ViewModel based Dialog on load

This is the simplest way to render a dialog. You can render a Card ViewModel by calling this.renderInDialog(...) from your SkillViewController or ViewController.

Test 1: Assert dialog is rendered on load

For this example, we’ll keep the dialog simple and render a Card ViewModel in the RootSkillViewController’s load() Lifecycle method.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'   

export default class RenderingADialogTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersAlertOnLoad() {
        const vc = this.views.Controller('eightbitstories.root', {})
        await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
    }
}
Production 1: Render a dialog on load
import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillView extends AbstractSkillViewController {
    public async load() {
        this.renderInDialog({
            header: {
                title: 'Hello, World!',
            },
        })
    }
}

Rendering a CardViewController based Dialog on load

Now lets make this dialog more powerful by rendering a CardViewController of our own!

Test 1: Assert dialog is rendered on load

This first test is very simple, just making sure a dialog is rendered.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert, vcPluginAssert } from '@sprucelabs/heartwood-view-controllers'

export default class RenderingADialogTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersAlertOnLoad() {
        const vc = this.views.Controller('eightbitstories.root', {})
        await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
    }
}
Production 1: Render a simple dialog on load
import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    public async load() {
        this.renderInDialog({})
    }

    public render() {
        return {}
    }
}
Test 2: Assert dialog is a specific type
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert, vcPluginAssert } from '@sprucelabs/heartwood-view-controllers'

export default class RenderingADialogTest extends AbstractSpruceFixtureTest {

    @test()
    protected static async rendersAlertOnLoad() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const dlgVc = await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
        vcAssert.assertRendersAsInstanceOf(dlgVc, MyCardViewController)
    }

}

You’re going to get a failure here because MyCardViewController doesn’t exist yet. Let’s create it!

Production 2a: Create MyCardViewController to fail next assertion

When you are creating your View, make sure to base it on a Card.

spruce create.view

Call it My Card (or whatever you want).

Note: Don’t add ViewController to the end of the name of ViewControllers. That’ll be added for you.

Note: It is helpful to add the name of the ViewModel being rendered. Examples: If you render a Card, end your name in Card. If you render a Form, end your name in Form.

Note: Don’t add Dialog to name of your ViewController. Because a CardViewController can be rendered in a dialog or in a SkillView, it is better to keep the name free from where it is rendered.

Production 2b: Make MyCardViewController render a Card

It is much better to use composition over inheritance. This is how you can make MyCardViewController render a CardViewController.

import { 
    CardViewController, 
    AbstractViewController, 
    Card 
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {

    public static id = 'my-card'
    private cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.Controller('card', {
            header: {
                title: 'Hello, World!',
            },
        })
    }

    public render(): CardViewController {
        return this.cardVc.render()
    }
}

Production 2c: Render MyCardViewController in the dialog

Back inside your RootSkillViewController, you can render MyCardViewController directly to .

import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    public async load() {
        const myCardVc = this.Controller('eightbitstories.my-card', {})
        this.renderInDialog(myCardVc.render())
    }

     public render() {
        return {}
    }
}

Running code when a Dialog is closed

Sometimes you’ll need to tear down some resources when a dialog is closed. You can do this by overriding the didHide() method in your ViewController. For this example, we’ll start start with the “Render a CardViewController based Dialog on load” example from above and we’ll use the scenario of wanting to remove event listeners when the dialog is closed.

Test 1a: Call load on Dialog's CardViewController

We have to start by checking if the load method is called on the MyViewController when the dialog is rendered.

Note: We are starting with the “Test 2” from above and adding the callsLoadOnMyCardAfterShowingAsDialog test.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import MyCardViewController from '../../viewControllers/MyCardViewController'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'

export default class RenderingADialogTest extends AbstractSpruceFixtureTest {

    @test()
    protected static async rendersAlertOnLoad() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const dlgVc = await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
        vcAssert.assertRendersAsInstanceOf(dlgVc, MyCardViewController)
    }

    @test()
    protected static async callsLoadOnMyCardAfterShowingAsDialog() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const dlgVc = await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
        const myCardVc = vcAssert.assertRendersAsInstanceOf(dlgVc, MyCardViewController)
    
        myCardVc.assertLoaded()
    }

}
Test 1b: Test double MyCardViewController

Your test should throw an error because MyCardViewController doesn’t have an assertLoaded() method. We’ll actually never add that, so we’re going to create a MockMyCardViewController that extends MyCardViewController and add the assertLoaded() method.

Note: I chose to create a Mock vs. a Spy (or any other test double) arbitrarily. You can use any test double you want, it only changes how you do the assertion.

import MyCardViewController from '../../viewControllers/MyCardViewController'
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'

export default class RenderingADialogTest extends AbstractSpruceFixtureTest {

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', MockMyCardViewController)
    }

    @test()
    protected static async rendersAlertOnLoad() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const dlgVc = await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
        vcAssert.assertRendersAsInstanceOf(dlgVc, MyCardViewController)
    }

    @test()
    protected static async callsLoadOnMyCardAfterShowingAsDialog() {
        const vc = this.views.Controller('eightbitstories.root', {})
        const dlgVc = await vcAssert.assertRendersDialog(vc, () => this.views.load(vc))
        const myCardVc = vcAssert.assertRendersAsInstanceOf(dlgVc, MyCardViewController) as MockMyCardViewController
    
        myCardVc.assertLoaded()
    }

}

class MockMyCardViewController extends MyCardViewController {
    private wasLoadCalled = false

    public async load() {
        await super.load()
        this.wasLoadCalled = true
    }

    public assertLoaded() {
        assert.isTrue(this.wasLoadCalled, `Expected load() to be called on MyCardViewController`)
    }
}

Note: The test will fail because MyCardViewController doesn’t have a load() method. Add that now to get to the last assertion.

Production 1: Call load() on MyCardViewController
import { AbstractSkillViewController } from '@sprucelabs/heartwood-view-controllers'

class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    public async load() {
        const myCardVc = this.Controller('eightbitstories.my-card', {})
        this.renderInDialog(myCardVc.render())

        await myCardVc.load()
    }

     public render() {
        return {}
    }
}
Test 2a: Dry the test

I moved a lot to the beforeEach() method and used the static state of the test class to hold the MyCardViewController instance. This makes it much easier to access in tests.

import MyCardViewController from '../../viewControllers/MyCardViewController'
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { vcAssert } from '@sprucelabs/heartwood-view-controllers'

export default class RenderingADialogTest extends AbstractSpruceFixtureTest {

    private static vc: MockMyCardViewController

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', MockMyCardViewController)
        this.vc = this.views.Controller('eightbitstories.root', {}) as MockMyCardViewController
    }

    @test()
    protected static async rendersAlertOnLoad() {
        await this.loadAndAssertRendersMyCard()
    }

    @test()
    protected static async callsLoadOnMyCardAfterShowingAsDialog() {
        const myCardVc = await this.loadAndAssertRendersMyCard()
        myCardVc.assertLoaded()
    }

    private static async load() {
        await this.views.load(this.vc)
    }

    private static async loadAndAssertRendersMyCard() {
        const dlgVc = await vcAssert.assertRendersDialog(this.vc, () => this.load())
        return vcAssert.assertRendersAsInstanceOf(dlgVc, MyCardViewController) as MockMyCardViewController
    }

}

class MockMyCardViewController extends MyCardViewController {
    private wasLoadCalled = false

    public async load() {
        await super.load()
        this.wasLoadCalled = true
    }

    public assertLoaded() {
        assert.isTrue(this.wasLoadCalled, `Expected load() to be called on MyCardViewController`)
    }
}

Test 2b: Start a new test for MyCardViewController

Now that we’ve tested that MyCardViewController is loaded in a dialog from RootSkillViewController, we can test MyCardViewController directly to minimize coupling in our tests.

spruce create.test

Call the test My Card View (or whatever you want) and drop it into behaviors.

Test 2c: Make sure listeners are set on load()

For brevity, I’m going to test that a listener is set and calls a private method on MyCardViewController when the event is emitted. This is only to give you an idea of how to test that listeners are set and removed. It may make sense to test double something to make sure it’s invoked when the event is emitted.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardViewTest extends AbstractSpruceFixtureTest {

    @test()
    protected static async setsListenersOnLoad() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        await vc.load()

        let wasHit = false
        vc.handleDidGenerateStory = async () => {
            wasHit = true
        }

        await this.fakeClient.emitAndFlattenResponse('eightbitstories.did-generate-story::v2024_01_01', {})

        assert.isTrue(wasHit, `Expected handleDidGenerateStory to be called in my Card`)
    }

}
Production 2: Add a listener in MyCardViewController
import { 
    CardViewController, 
    AbstractViewController, 
    Card 
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {

    public static id = 'my-card'
    private cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.CardVc()
    }

    private CardVc() {
        return this.Controller('card', {
            header: {
                title: 'Hello, World!',
            },
        })
    }

    public async load() {
        const client = await this.connectToApi()
        await client.on('eightbitstories.did-generate-story::v2024_01_01', async () => this.handleDidGenerateStory())
    }

    private async handleDidGenerateStory() {
        // Do something when the event is emitted
    }

    public render(): CardViewController {
        return this.cardVc.render()
    }
}

Note: I also extracted the construction of the CardViewController to a private method to simplify the constructor.

Test 3: Make sure listeners are removed on when the Dialog was hidden

We are going to essentially copy the last test but add interactor.hide(vc) before emitting the event. Once the tests passes, we’ll refactor.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { interactor } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardViewTest extends AbstractSpruceFixtureTest {

    @test()
    protected static async setsListenersOnLoad() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        await vc.load()

        let wasHit = false
        vc.handleDidGenerateStory = async () => {
            wasHit = true
        }

        await this.fakeClient.emitAndFlattenResponse('eightbitstories.did-generate-story::v2024_01_01', {})

        assert.isTrue(wasHit, `Expected handleDidGenerateStory to be called in my Card`)
    }

    @test()
    protected static async removesListenersOnHide() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        await vc.load()

        let wasHit = false
        vc.handleDidGenerateStory = async () => {
            wasHit = true
        }

        await interactor.hide(vc)

        await this.fakeClient.emitAndFlattenResponse('eightbitstories.did-generate-story::v2024_01_01', {})

        assert.isFalse(wasHit, `Expected handleDidGenerateStory to not be called in MyCard`)

    }

}
Production 3: Remove listeners on hide()
import { 
    CardViewController, 
    AbstractViewController, 
    Card 
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {

    public static id = 'my-card'
    private cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.CardVc()
    }

    private CardVc() {
        return this.Controller('card', {
            header: {
                title: 'Hello, World!',
            },
        })
    }

    public async load() {
        const client = await this.connectToApi()
        await client.on('eightbitstories.did-generate-story::v2024_01_01', async () => this.handleDidGenerateStory())
    }

    private async handleDidGenerateStory() {
        // Do something when the event is emitted
    }

    public async didHide() {
        const client = await this.connectToApi()
        await client.off('eightbitstories.did-generate-story::v2024_01_01')
    }

    public render(): CardViewController {
        return this.cardVc.render()
    }
}

Note: The client.off(...) method accepts 2 arguments. The first is the event name and the second is the callback. If you don’t pass the callback, all listeners for that event are removed. This may prove to be a problem if you have multiple listeners for the same event.

Test 4a: Dry the test

Once again, we are going to move repetitive code to the beforeEach() method and use the static state of the test class to hold helpful details. We’ll also move the monkey patching to beforeEach() to make refactoring it easier.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { interactor } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardViewTest extends AbstractSpruceFixtureTest {
    private static vc: MyCardViewController
    private static wasDidGenerateStoryCalled = false

    public static async beforeEach() {
        await super.beforeEach()
        
        this.wasDidGenerateStoryCalled = true
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as MyCardViewController
       
        this.vc.handleDidGenerateStory = async () => {
            this.wasDidGenerateStoryCalled = true
        }

        await this.vc.load()
    }

    @test()
    protected static async setsListenersOnLoad() {
        await this.emitDidGenerateStory()
        assert.isTrue(this.wasDidGenerateStoryCalled, `Expected handleDidGenerateStory to be called in my Card`)
    }

    @test()
    protected static async removesListenersOnHide() {
        await interactor.hide(this.vc)
        await this.emitDidGenerateStory()
        assert.isFalse(this.wasDidGenerateStoryCalled, `Expected handleDidGenerateStory to not be called in MyCard`)
    }

    private static async emitDidGenerateStory() {
        await this.fakeClient.emitAndFlattenResponse('eightbitstories.did-generate-story::v2024_01_01', {})
    }
}
Test 4b: Ensure proper listeners turned off (Optional)

You only need to follow this if you need to make sure you are removing the correct listener. This is a little more complicated when using monkey patching because of the way that javascript handles scope and this. So, this will require a little bit of refactoring to keep past tests passing.

We’re going to start by creating the failing test, then refactor from there.

Note: I also refactored the test to cut down on repetition. See this.hideAndEmitDidGenerateStory() for more information.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { interactor } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardViewTest extends AbstractSpruceFixtureTest {
    private static vc: MyCardViewController
    private static wasDidGenerateStoryCalled = false

    public static async beforeEach() {
        await super.beforeEach()
        
        this.wasDidGenerateStoryCalled = true
        this.vc = this.views.Controller('eightbitstories.my-card', {})
       
        this.vc.handleDidGenerateStory = async () => {
            this.wasDidGenerateStoryCalled = true
        }

        await this.vc.load()
    }

    @test()
    protected static async setsListenersOnLoad() {
        await this.emitDidGenerateStory()
        assert.isTrue(this.wasDidGenerateStoryCalled, `Expected handleDidGenerateStory to be called in my Card`)
    }

    @test()
    protected static async removesListenersOnHide() {
        await this.hideAndEmitDidGenerateStory()
        assert.isFalse(this.wasDidGenerateStoryCalled, `Expected handleDidGenerateStory to not be called in MyCard`)
    }

    @test()
    protected static async removesTheCorrectListener() {
        let wasHit = false

        await this.fakeClient.on('eightbitstories.did-generate-story::v2024_01_01', () => {
            wasHit = true
        })

        await this.hideAndEmitDidGenerateStory()

        assert.isTrue(wasHit, `Oops, I removed too many listeners`)
    }

    private static async emitDidGenerateStory() {
        await this.hide()
        await this.emitDidGenerateStory()
    }

    private static async emitDidGenerateStory() {
        await this.fakeClient.emitAndFlattenResponse('eightbitstories.did-generate-story::v2024_01_01', {})
    }

    private static async hide() {
        await interactor.hide(this.vc)
    }
}
Production 4: Remove proper listener

We’re going to have to work around the way javascript handles scope (and this) to make this work. So, we’ll first make it pass, then refactor in a way that is cleaner.

import { 
    CardViewController, 
    AbstractViewController, 
    Card 
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {

    public static id = 'my-card'
    private cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.CardVc()
    }

    private CardVc() {
        return this.Controller('card', {
            header: {
                title: 'Hello, World!',
            },
        })
    }

    public async load() {
        const client = await this.connectToApi()
        await client.on('eightbitstories.did-generate-story::v2024_01_01', this.handleListener)
    }

    private handleListener = async () => {
        return this.handleDidGenerateStory()
    }

    private async handleDidGenerateStory() {
        // Do something when the event is emitted
    }

    public async didHide() {
        const client = await this.connectToApi()
        await client.off('eightbitstories.did-generate-story::v2024_01_01', this.handleListener)
    }

    public render(): CardViewController {
        return this.cardVc.render()
    }
}

Note: Notice how I used an arrow function to maintain this and had it call this.handleDidGenerateStory(). This will allow us to remove the listener properly while not breaking the tests.

Test 5: Move to test double

Just for demonstration’s sake, I’m going to move to a Spy to make sure that handleDidGenerateStory() is called when the event is emitted. This will actually simplify the test and case could be made we should have started there, but the power of these testing practices is we get to make the decision on how to best design a solution after it’s actually built. Bonus, this does away with the monkey patching we did before.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { interactor } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardViewTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCardViewController
    

    public static async beforeEach() {
        await super.beforeEach()
        
        this.views.setController('eightbitstories.my-card', SpyMyCardViewController)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCardViewController

        await this.vc.load()
    }

    @test()
    protected static async setsListenersOnLoad() {
        await this.emitDidGenerateStory()
        assert.isTrue(this.wasDidGenerateStoryCalled, `Expected handleDidGenerateStory to be called in my Card`)
    }

    @test()
    protected static async removesListenersOnHide() {
        await this.hideAndEmitDidGenerateStory()
        assert.isFalse(this.wasDidGenerateStoryCalled, `Expected handleDidGenerateStory to not be called in MyCard`)
    }

    @test()
    protected static async removesTheCorrectListener() {
        let wasHit = false

        await this.fakeClient.on('eightbitstories.did-generate-story::v2024_01_01', () => {
            wasHit = true
        })

        await this.hideAndEmitDidGenerateStory()

        assert.isTrue(wasHit, `Oops, I removed too many listeners`)
    }

    private static get wasDidGenerateStoryCalled() {
        return this.vc.wasHandleDidGenerateStoryCalled
    }

    private static async emitDidGenerateStory() {
        await this.hide()
        await this.emitDidGenerateStory()
    }

    private static async emitDidGenerateStory() {
        await this.fakeClient.emitAndFlattenResponse('eightbitstories.did-generate-story::v2024_01_01', {})
    }

    private static async hide() {
        await interactor.hide(this.vc)
    }
}

class SpyMyCardViewController extends MyCardViewController {
    public wasHandleDidGenerateStoryCalled = false

    public async handleDidGenerateStory() {
        await super.handleDidGenerateStory()
        this.wasHandleDidGenerateStoryCalled = true
    }
}

Here is an overview of what I did:

  1. I created a Spy that extends MyCardViewController and added a public property called wasHandleDidGenerateStoryCalled.
  2. Did away with the local property wasDidGenerateStoryCalled on the test class and replaced it with a getter that returns the Spy’s property.
  3. Removed the monkey patching all together.
Production 5: Simplify implementation

Because we’ve moved to a Spy, we can simplify the implementation of MyCardViewController by removing the additional method this.handleListener(). But, we’ll need to also change this.handleDidGenerateStory() to an arrow function to maintain this.

import { 
    CardViewController, 
    AbstractViewController, 
    Card 
} from '@sprucelabs/heartwood-view-controllers'

export default class MyCardViewController extends AbstractViewController<Card> {

    public static id = 'my-card'
    private cardVc: CardViewController

    public constructor(options: ViewControllerOptions) {
        super(options)
        this.cardVc = this.CardVc()
    }

    private CardVc() {
        return this.Controller('card', {
            header: {
                title: 'Hello, World!',
            },
        })
    }

    public async load() {
        const client = await this.connectToApi()
        await client.on('eightbitstories.did-generate-story::v2024_01_01', this.handleDidGenerateStory)
    }


    private async handleDidGenerateStory = async () => {
        // Do something when the event is emitted
    }

    public async didHide() {
        const client = await this.connectToApi()
        await client.off('eightbitstories.did-generate-story::v2024_01_01', this.handleDidGenerateStory)
    }

    public render(): CardViewController {
        return this.cardVc.render()
    }
}

Rendering Forms

Forms, like all other components, are render in the CardSection of your Card. You will rely heavily on the formAssert utility for testing. Before getting too deep, it’ll be helpful to understand some Schema basics.

Rendering a Form

Test 1: Assert form is rendered in your card
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { formAssert } from '@sprucelabs/heartwood-view-controllers'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersACard() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        formAssert.cardRendersForm(vc)
    }
}
Production 1: Render an empty form in your card

This is a big first step, but pay attention to a few things:

  1. You construct your form using the buildForm utility for better typing.
  2. The FormViewController interface is a generic, so it’ll take the type of your Schema to enable advanced typing.
  3. You render your Form into the form property of your CardSection.
import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.formVc = this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [],
            })
        )
        this.cardVc = this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {},
})

type MyFormSchema = typeof myFormSchema

Note: You will rely on buildForm to get better typing while constructing your form.

Test 2a: Asserting desired fields are rendered

Our goal is to check that a desired field is being rendered, but first we’ll get blocked by needing to expose our formVc. We’ll do that using a test double.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { formAssert } from '@sprucelabs/heartwood-view-controllers'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersACard() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        formAssert.cardRendersForm(vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        formAssert.formRendersFields(vc.getForm(), ['destination'])
    }
}

Note: You should see an error that getForm() doesn’t exist. We’ll create a Spy to fix that.

Test 2b: Create a Spy for MyCardViewController
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { formAssert } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersACard() {
        const vc = this.views.Controller('eightbitstories.my-card', {})
        formAssert.cardRendersForm(vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        this.views.setController('eightbitstories.my-card', SpyMyCard)
        const vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
        formAssert.formRendersFields(vc.getForm(), ['field1','field2'])
    }
}

class SpyMyCard extends MyCardViewController {
    public getForm() {
        return this.formVc
    }
}

Note: If you are following along, you will get a type error because formVc is private. You can make it protected in MyCardViewController to get around this.

Note: Now you should get an error that your form is not rendering the expected fields. It’s time to implement the fields in your form.

Production 2a: Render the expected fields in your form

Getting your fields to render is a 2-step process:

  1. Add the fields to your Schema.
  2. Add the fields as a Section to your Form.

This separation allows you to have “source of truth” in your Schema and then render the fields you actually want in your form.

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.formVc = this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [
                    {
                        fields: ['field1', 'field2'],
                    }
                ],
            })
        )
        this.cardVc = this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {
        field1: {
            type: 'text',
            label: 'Field 1',
        },
        field2: {
            type: 'text',
            label: 'Field 2',
        },
    },
})

type MyFormSchema = typeof myFormSchema

Production 2b: Cleanup your ViewController

Let’s take a sec to cleanup our ViewController's constructor to make it more readable.

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.formVc = FormVc()
        this.cardVc = CardVc()
    }

    private FormVc() {
        return this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [
                    {
                        fields: ['field1', 'field2'],
                    }
                ],
            })
        )
    }

    private CardVc() {
        return this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {
        field1: {
            type: 'text',
            label: 'Field 1',
        },
        field2: {
            type: 'text',
            label: 'Field 2',
        },
    },
})

type MyFormSchema = typeof myFormSchema

Test 3: Dry your tests

Once again, we’re going to utilize our Test Class’s static state to cut down on duplication.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { formAssert } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
    }

    @test()
    protected static async rendersACard() {
        formAssert.cardRendersForm(this.vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        formAssert.formRendersFields(this.vc.getForm(), ['field1','field2'])
    }
}

class SpyMyCard extends MyCardViewController {
    public getForm() {
        return this.formVc
    }
}

Rendering an Autocomplete Input

The AutocompleteInputViewController is a text input that provides suggestions as you type. Well, you gotta supply the suggestions, but it’s pretty easy.

It’s important to note that the ‘AutocompleteInputViewController’, like all InputViewControllers, has a renderedValue and a value. The renderedValue is what is what is displayed in the input, while the value is what is submitted in the form.

With an AutocompleteInput, we’ll pay attention to changes in the renderedValue as the user types, and then update the value when the user selects a suggestion.

This series of tests is going to pickup where the tests above left off.

Test 1: Assert that field renders using AutocompleteInput
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { formAssert, AutocompleteInputViewController } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
    }

    @test()
    protected static async rendersACard() {
        formAssert.cardRendersForm(this.vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        formAssert.formRendersFields(this.formVc, ['field1','field2'])
    }

    @test()
    protected static async rendersAutocompleteInput() {
        formAssert.fieldRendersUsingInstanceOf(
            this.formVc,
            'field1',
            AutocompleteInputViewController
        )
    }

    protected static get formVc() {
        return this.vc.getForm()
    }
}

class SpyMyCard extends MyCardViewController {
    public getForm() {
        return this.formVc
    }
}

Note: In addition to the formAssert.fieldRendersUsingInstanceOf assertion, we’ve added a formVc getter to cut down on duplication.

Production 1: Render an AutocompleteInput in your form

This is another 2 parter:

  1. Construct an AutocompleteInputViewController and track it on your ViewController.
  2. Update your FormSection to be the “expanded” type, which is an object with field and vc properties (among others).
import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
    AutocompleteInputViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>
    private autocompleteInputVc: AutocompleteInputViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.autocompleteInputVc = this.AutocompleteVc()
        this.formVc = FormVc()
        this.cardVc = CardVc()
    }

    private AutocompleteVc(): AutocompleteInputViewController {
        return this.Controller('autocomplete-input', {})
    }

    private FormVc() {
        return this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [
                    {
                        fields: [
                            {
                                name: 'field1'
                                vc: this.autocompleteInputVc,
                            }, 
                            'field2'
                        ],
                    }
                ],
            })
        )
    }

    private CardVc() {
        return this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {
        field1: {
            type: 'text',
            label: 'Field 1',
        },
        field2: {
            type: 'text',
            label: 'Field 2',
        },
    },
})

type MyFormSchema = typeof myFormSchema

Test 2: Assert the field's renderAs

We’ll use formAssert.fieldRendersAs to assert that the field is rendering as an autocomplete.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { formAssert, AutocompleteInputViewController } from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
    }

    @test()
    protected static async rendersACard() {
        formAssert.cardRendersForm(this.vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        formAssert.formRendersFields(this.formVc, ['field1','field2'])
    }

    @test()
    protected static async rendersAutocompleteInput() {
        formAssert.fieldRendersUsingInstanceOf(
            this.formVc,
            'field1',
            AutocompleteInputViewController
        )
    }

    @test()
    protected static async rendersAsAutocomplete() {
        formAssert.fieldRendersAs(
            this.formVc,
            'field1',
            'autocomplete'
        )
    }

    protected static get formVc() {
        return this.vc.getForm()
    }
}

class SpyMyCard extends MyCardViewController {
    public getForm() {
        return this.formVc
    }
}

Production 2: Set field to render as autocomplete

A quick, easy add. Simple set the renderAs property to autocomplete in field.

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
    AutocompleteInputViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>
    protected autocompleteInputVc: AutocompleteInputViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.autocompleteInputVc = this.AutocompleteVc()
        this.formVc = FormVc()
        this.cardVc = CardVc()
    }

    private AutocompleteVc(): AutocompleteInputViewController {
        return this.Controller('autocomplete-input', {
            onChangeRenderedValue: () =>
                this.autocompleteInputVc.showSuggestions([]),
        })
    }

    private FormVc() {
        return this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [
                    {
                        fields: [
                            {
                                name: 'field1'
                                vc: this.autocompleteInputVc,
                                renderAs: 'autocomplete',
                            }, 
                            'field2'
                        ],
                    }
                ],
            })
        )
    }

    private CardVc() {
        return this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {
        field1: {
            type: 'text',
            label: 'Field 1',
        },
        field2: {
            type: 'text',
            label: 'Field 2',
        },
    },
})

type MyFormSchema = typeof myFormSchema

Test 3: Assert that the AutocompleteInput shows suggestions

The next steps are:

  1. Use the autocompleteAssert utility to assert that the AutocompleteInputViewController shows suggestions when the renderedValue is changed.
  2. Update your Spy to expose the AutocompleteInputViewController with getAutocompleteVc().
import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'
import {
    autocompleteAssert,
    AutocompleteInputViewController,
    formAssert,
} from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
    }

    @test()
    protected static async rendersACard() {
        formAssert.cardRendersForm(this.vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        formAssert.formRendersFields(this.formVc, ['field1','field2'])
    }

    @test()
    protected static async rendersAutocompleteInput() {
        formAssert.fieldRendersUsingInstanceOf(
            this.formVc,
            'field1',
            AutocompleteInputViewController
        )
    }

    @test()
    protected static async rendersAsAutocomplete() {
        formAssert.fieldRendersAs(
            this.formVc,
            'field1',
            'autocomplete'
        )
    }

    @test()
    protected static async changingDestinationsRendersSuggestions() {
        await autocompleteAssert.actionShowsSuggestions(
            this.vc.getAutocompleteVc(),
            () => this.vc.getAutocompleteVc().setRenderedValue('test')
        )
    }

    protected static get formVc() {
        return this.vc.getForm()
    }
}

class SpyMyCard extends MyCardViewController {
    public getAutocompleteVc() {
        return this.autocompleteInputVc
    }

    public getForm() {
        return this.formVc
    }
}

Note: You’re going to get an type error because autocompleteInputVc is ‘private’. You can make it ‘protected’ in MyCardViewController to get around this.

Production 3: Render suggestions in your AutocompleteInput

Notice how we added a onChangeRenderedValue callback to the AutocompleteInputViewController to show suggestions when the renderedValue changes and just pass an empty array for now.

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
    AutocompleteInputViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>
    protected autocompleteInputVc: AutocompleteInputViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.autocompleteInputVc = this.AutocompleteVc()
        this.formVc = FormVc()
        this.cardVc = CardVc()
    }

    private AutocompleteVc(): AutocompleteInputViewController {
        return this.Controller('autocomplete-input', {
            onChangeRenderedValue: () =>
                this.autocompleteInputVc.showSuggestions([]),
        })
    }

    private FormVc() {
        return this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [
                    {
                        fields: [
                            {
                                name: 'field1'
                                vc: this.autocompleteInputVc,
                            }, 
                            'field2'
                        ],
                    }
                ],
            })
        )
    }

    private CardVc() {
        return this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {
        field1: {
            type: 'text',
            label: 'Field 1',
        },
        field2: {
            type: 'text',
            label: 'Field 2',
        },
    },
})

type MyFormSchema = typeof myFormSchema

Test 4: Assert changing the renderedValue emits an event.
import { AbstractSpruceFixtureTest, eventFaker } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'
import {
    autocompleteAssert,
    AutocompleteInputViewController,
    formAssert,
} from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard

    protected static async beforeEach() {
        await super.beforeEach()
        this.views.setController('eightbitstories.my-card', SpyMyCard)
        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
    }

    @test()
    protected static async rendersACard() {
        formAssert.cardRendersForm(this.vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        formAssert.formRendersFields(this.formVc, ['field1','field2'])
    }

    @test()
    protected static async rendersAutocompleteInput() {
        formAssert.fieldRendersUsingInstanceOf(
            this.formVc,
            'field1',
            AutocompleteInputViewController
        )
    }

    @test()
    protected static async changingDestinationsRendersSuggestions() {
        await autocompleteAssert.actionShowsSuggestions(
            this.autocompleteInputVc,
            () => this.typeIntoField1('test')
        )
    }

    @test()
    protected static async typeingIntoField1EmitsEvent() {
        let wasHit = false
        await eventFaker.on('eightbitstories.autocomplete-event::v2020_01_01', () => {
            wasHit = true
            return []
        })

        await this.typeIntoField1('hello world')
        
        assert.isTrue(wasHit)
    }

    protected static get autocompleteVc() {
        return this.vc.getAutocompleteVc()
    }

    protected static async typeIntoField1(value: string) {
       return this.autocompleteVc.setRenderedValue(value)
    }

    protected static get formVc() {
        return this.vc.getForm()
    }
}

class SpyMyCard extends MyCardViewController {
    public getAutocompleteVc() {
        return this.autocompleteInputVc
    }

    public getForm() {
        return this.formVc
    }
}

Production 4: Emit an event when the renderedValue changes

Time to change the onChangeRenderedValue handler to emit an event when the renderedValue changes.

import {
    AbstractViewController,
    ViewControllerOptions,
    Card,
    CardViewController,
    buildForm,
    FormViewController,
    AutocompleteInputViewController,
} from '@sprucelabs/heartwood-view-controllers'
import { buildSchema } from '@sprucelabs/schema'

export default class MyCardViewController extends AbstractViewController<Card> {
    public static id = 'my-card'
    private cardVc: CardViewController
    protected formVc: FormViewController<MyFormSchema>
    protected autocompleteInputVc: AutocompleteInputViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.autocompleteInputVc = this.AutocompleteVc()
        this.formVc = FormVc()
        this.cardVc = CardVc()
    }

    private AutocompleteVc(): AutocompleteInputViewController {
        return this.Controller('autocomplete-input', {
            onChangeRenderedValue: (value) =>
                this.handleAutocompleteChange(value),
        })
    }

    private async handleAutocompleteChange(_value: string) {
        this.autocompleteInputVc.showSuggestions([])
        const client = await this.connectToApi()
        await client.emitAndFlattenResponses(
            'eightbitstories.autocomplete-event::v2020_01_01'
        )
    }

    private FormVc() {
        return this.Controller(
            'form',
            buildForm({
                schema: myFormSchema,
                sections: [
                    {
                        fields: [
                            {
                                name: 'field1'
                                vc: this.autocompleteInputVc,
                            }, 
                            'field2'
                        ],
                    }
                ],
            })
        )
    }

    private CardVc() {
        return this.Controller('card', {
            body: {
                sections: [
                    {
                        form: this.formVc.render(),
                    },
                ],
            },
        })
    }

    public render() {
        return this.cardVc.render()
    }
}

const myFormSchema = buildSchema({
    id: 'myForm',
    fields: {
        field1: {
            type: 'text',
            label: 'Field 1',
        },
        field2: {
            type: 'text',
            label: 'Field 2',
        },
    },
})

type MyFormSchema = typeof myFormSchema

Note: We still show an empty array of suggestions to keep the past test working.

Note: You should be getting an error that a listener for eightbitstories.autocomplete-event::v2020_01_01 doesn’t exist for the last test, we’ll refactor our test next to make it work.

Note: To avoid an unused variable warning, you can prepend the variable name with an underscore (_value).

Test 5a: Fix the previous test + EventFaker

We’re going to take a short detour now to create an EventFaker class to keep our tests DRY.

import { AbstractSpruceFixtureTest, eventFaker } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'
import {
    autocompleteAssert,
    AutocompleteInputViewController,
    formAssert,
} from '@sprucelabs/heartwood-view-controllers'
import MyCardViewController from '../../viewControllers/MyCardViewController'

export default class MyCardTest extends AbstractSpruceFixtureTest {
    private static vc: SpyMyCard
    private static eventFaker: EventFaker

    protected static async beforeEach() {
        await super.beforeEach()

        this.views.setController('eightbitstories.my-card', SpyMyCard)

        this.vc = this.views.Controller('eightbitstories.my-card', {}) as SpyMyCard
        this.eventFaker = new EventFaker()
        
        await this.eventFaker.fakeAutocompleteEvent()
    }

    @test()
    protected static async rendersACard() {
        formAssert.cardRendersForm(this.vc)
    }

    @test()
    protected static async rendersExpectedFields() {
        formAssert.formRendersFields(this.formVc, ['field1','field2'])
    }

    @test()
    protected static async rendersAutocompleteInput() {
        formAssert.fieldRendersUsingInstanceOf(
            this.formVc,
            'field1',
            AutocompleteInputViewController
        )
    }

    @test()
    protected static async changingDestinationsRendersSuggestions() {
        await autocompleteAssert.actionShowsSuggestions(
            this.autocompleteInputVc,
            () => this.typeIntoField1('test')
        )
    }

    @test()
    protected static async typeingIntoField1EmitsEvent() {
        let wasHit = false
        await this.eventFaker.fakeAutocompleteEvent(() => {
            wasHit = true
            return []
        })

        await this.typeIntoField1('hello world')
        
        assert.isTrue(wasHit)
    }

    protected static get autocompleteVc() {
        return this.vc.getAutocompleteVc()
    }

    protected static async typeIntoField1(value: string) {
       return this.autocompleteVc.setRenderedValue(value)
    }

    protected static get formVc() {
        return this.vc.getForm()
    }
}

class SpyMyCard extends MyCardViewController {
    public getAutocompleteVc() {
        return this.autocompleteInputVc
    }

    public getForm() {
        return this.formVc
    }
}

class EventFaker {
    public async fakeAutocompleteEvent(cb?: () => void) {
        return this.fakeEvent('eightbitstories.autocomplete-event::v2020_01_01', () => {
            return {
                results: [],
            }
        })
    }
}

Note: By adding the EventFaker class and faking the event in beforeEach, we can be sure that no test fails because of a missing listener.

Test 5b: Assert the expected target & payload

Crud Operations

If you need to build out data management views quickly (something akin to a CMS), you can can leverage the @sprucelabs/spruce-crud-utils. Before digging in, here is an overview of the Views that you will be utilizing to build out your CRUD views.

Installing dependencies

Because the CRUD Views and assertions are provided by the @sprucelabs/spruce-crud-utils module, let’s start by adding it to our project.

yarn add @sprucelabs/spruce-crud-utils

Configuring your MasterSkillView

In this walkthrough, we’ll assume you want your RootSkillView to render as a MasterSkillView, but you can obviously use any SkillView you’d like.

Test 1a:Assert renders as MasterSkillView

Notice that we’re importing crudAssert from @sprucelabs/spruce-crud-utils and using it to assert that the RootSkillView renders as a MasterSkillView.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'
import { crudAssert } from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {
    @test()
    protected static async rendersMaster() {
        const vc = this.views.Controller('eightbitstories.root', {})
        crudAssert.skillViewRendersMasterView(]vc)
    }
}

Note: This is going to be mad about a beforeEach() setup that needs to be done, follow the instructions to get to the next step.

Test 1b:Fix the crudAssert

The fix happens in your tests beforeEach() method.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'
import { crudAssert } from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {

    protected static async beforeEach() {
        await super.beforeEach()
        crudAssert.beforeEach(this.views)
    }


    @test()
    protected static async rendersMaster() {
        const vc = this.views.Controller('eightbitstories.root', {})
        crudAssert.skillViewRendersMasterView(]vc)
    }
}

Note: Now it’s going to blow up about not setting up the Crud Views properly. We’ll do this in your RootSkillView or wherever you’re rendering your MasterSkillView.

Production 1a:Configure the ViewControllerFactory

Since we’re starting with an empty SkillView, we’ll implement just the constructor and call setController(...) on the ViewControllerFactory to set the CrudMasterSkillViewController and MasterListCardViewController.

import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
} from '@sprucelabs/heartwood-view-controllers'
import {
    CrudMasterSkillViewController,
    MasterListCardViewController,
} from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.getVcFactory().setController(
            'crud.master-skill-view',
            CrudMasterSkillViewController
        )
        this.getVcFactory().setController(
            'crud.master-list-card',
            MasterListCardViewController
        )

    }

    public render(): SkillView {
        return {}
    }
}

> **Note**: This will bring you to the next failing assertion, which requires you to actually render a `MasterSkillView`.

Production 1b:Render your MasterSkillView

Also, the MasterSkillView requires at least one entity, so use buildMasterListEntity(...) to create one (you can put in gibberish for now).

Here are the steps:

  1. Construct a MasterSkillView
  2. Pass it at least one entity (using buildMasterListEntity(...)) and put in gibberish for now.
  3. Render the MasterSkillView by updating the render() method in your RootSkillView.
import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
} from '@sprucelabs/heartwood-view-controllers'
import {
    CrudMasterSkillViewController,
    MasterListCardViewController,
} from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'
    private masterSkillViewVc: CrudMasterSkillViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.getVcFactory().setController(
            'crud.master-skill-view',
            CrudMasterSkillViewController
        )
        this.getVcFactory().setController(
            'crud.master-list-card',
            MasterListCardViewController
        )

        this.masterSkillViewVc = this.Controller('crud.master-skill-view', {
            entities: [
                buildMasterListEntity({
                    id: 'aoeu',
                    title: 'aoeu',
                    load: {
                        fqen: 'aoeu',
                        responseKey: 'aoue',
                        rowTransformer: (skill) => ({
                            id: 'aoeuaoeu',
                            cells: [],
                        }),
                    },
                }),
            ],
        })
    }

    public render(): SkillView {
        return this.masterSkillViewVc.render()
    }
}


Note: You are going to get a lot of type errors, which is fine, because we’ll get to that next test!

Test 2:Assert MasterSkillView is loaded

This one is fast, let’s use crudAssert.skillViewLoadsMasterView() to assert that the MasterSkillView is loaded when your SkillView is loaded.

import { AbstractSpruceFixtureTest } from '@sprucelabs/spruce-test-fixtures'
import { test } from '@sprucelabs/test-utils'
import { crudAssert } from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewTest extends AbstractSpruceFixtureTest {

    protected static async beforeEach() {
        await super.beforeEach()
        crudAssert.beforeEach(this.views)
    }


    @test()
    protected static async rendersMaster() {
        const vc = this.views.Controller('eightbitstories.root', {})
        crudAssert.skillViewRendersMasterView(]vc)
    }

    @test()
    protected static async loadsMaster() {
        const vc = this.views.Controller('eightbitstories.root', {})
        await crudAssert.skillViewLoadsMasterView(vc)
    }
}

Production 2a:Implement MasterSkillView loading

To get this test to pass, you need to implement the load() method in your SkillView and call load(...) on the MasterSkillView.

import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
    SkillViewControllerLoadOptions,
} from '@sprucelabs/heartwood-view-controllers'
import {
    CrudMasterSkillViewController,
    MasterListCardViewController,
} from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'
    private masterSkillViewVc: CrudMasterSkillViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.getVcFactory().setController(
            'crud.master-skill-view',
            CrudMasterSkillViewController
        )
        this.getVcFactory().setController(
            'crud.master-list-card',
            MasterListCardViewController
        )

        this.masterSkillViewVc = this.Controller('crud.master-skill-view', {
            entities: [
                buildMasterListEntity({
                    id: 'aoeu',
                    title: 'aoeu',
                    load: {
                        fqen: 'aoeu',
                        responseKey: 'aoue',
                        rowTransformer: (skill) => ({
                            id: 'aoeuaoeu',
                            cells: [],
                        }),
                    },
                }),
            ],
        })
    }

    public async load(options: SkillViewControllerLoadOptions) {
        await this.masterSkillViewVc.load(options)
    }

    public render(): SkillView {
        return this.masterSkillViewVc.render()
    }
}

Production 2b: Cleanup constructor
import {
    AbstractSkillViewController,
    ViewControllerOptions,
    SkillView,
    SkillViewControllerLoadOptions,
} from '@sprucelabs/heartwood-view-controllers'
import {
    CrudMasterSkillViewController,
    MasterListCardViewController,
} from '@sprucelabs/spruce-crud-utils'

export default class RootSkillViewController extends AbstractSkillViewController {
    public static id = 'root'
    private masterSkillViewVc: CrudMasterSkillViewController

    public constructor(options: ViewControllerOptions) {
        super(options)

        this.getVcFactory().setController(
            'crud.master-skill-view',
            CrudMasterSkillViewController
        )
        this.getVcFactory().setController(
            'crud.master-list-card',
            MasterListCardViewController
        )

        this.masterSkillViewVc = this.MasterSkillViewVc()
    }

    private MastSkillViewVc() {
        return this.Controller('crud.master-skill-view', {
            entities: [
                buildMasterListEntity({
                    id: 'aoeu',
                    title: 'aoeu',
                    load: {
                        fqen: 'aoeu',
                        responseKey: 'aoue',
                        rowTransformer: (skill) => ({
                            id: 'aoeuaoeu',
                            cells: [],
                        }),
                    },
                }),
            ],
        })
    }

    public async load(options: SkillViewControllerLoadOptions) {
        await this.masterSkillViewVc.load(options)
    }

    public render(): SkillView {
        return this.masterSkillViewVc.render()
    }
}

Configuring your DetailSkillView

Now that you have your MasterSkillView rendering, let’s configure your DetailSkillView to render as a CrudDetailSkillView.

Rendering a Location’s Address

A location’s address (location.address) can be rendered easily by user the locationRenderer utility provided by @psrucelabs/spruce-skill-utils module.

import { locationRenderer } from '@sprucelabs/spruce-skill-utils'
console.log(locationRenderer.renderAddress(location.address))

The NavigationViewController is rendered in the Control Bar. You don’t have any control over the Control Bar, but you have full control over the NavigationViewController.

Rendering custom navigation

Redirecting when clicking navigation

View Controller Plugins

You can globally enhance View Controller functionality by using View Controller Plugins. Here are some plugins that are already available:

  1. AutoLogoutPlugin: Automatically logs out a person after a certain period of inactivity. You can set the timeout in seconds and also disable it where desired.
  2. AdjustMmpVcPlugin: Used to communicate with the MMP (Mobile Measurement Partners) Adjust. Others like AppsFlyer may come later. It currently only works inside the Spruce native iOS app.

Implementing a View Controller Plugin

Test 1a: Assert the plugin is installed
import {
    vcAssert,
    vcPluginAssert,
} from '@sprucelabs/heartwood-view-controllers'
import { AutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils'

export default class AutoLoggingOutTest extends AbstractSpruceFixtureTest {

    @test()
    protected static async autoLogoutPluginInstalled() {
        vcPluginAssert.pluginIsInstalled(
            this.views.Controller('eightbitstories.root', {}),
            'autoLogout',
            AutoLogoutPlugin
        )
    }
}

Note: If you are planning on using your own plugin (one that is not built yet), use it instead of AutoLogoutPlugin as if it exists and then begin with the productions steps below.

Production 1: Install the plugin
  1. Install the module that holds the plugin: yarn add {packageName}
  2. Create the plugin: spruce create.view.plugin
  3. Implement the plugin at ./src/viewPlugins/{pluginName}.ts

Your plugin starts like this:

import { ViewControllerPlugin } from '@sprucelabs/heartwood-view-controllers'

export default class MyViewPlugin implements ViewControllerPlugin {
    ...
}

Now that plugin is created, you can import it into your test.

Note: If you are using a prebuilt plugin, you would implement it like this:

export { AutoLogoutPlugin as default } from '@sprucelabs/spruce-heartwood-utils'
Test Doubling Your Plugin

You can drop in your test double using the views fixture on your AbstractSpruceFixtureTest . Here is how you may do that in your beforeEach():

protected static async beforeEach() {
    await super.beforeEach()

    this.spy = new SpyPlugin()
    this.views.addPlugin('autoLogout', this.spy)
}

Now, in any View Controller you create, this.plugins.autoLogout will be the SpyPlugin instance.

class RootSkillView extends AbstractSkillViewController {
    public constructor(options: SkillViewControllerOptions) {
        super(options)
    }

    public async load() {
        this.plugins.autoLogout.doSomething()
    }
}

Something Missing?

Request Documentation Enhancement
It looks like you are using Internet Explorer. While the basic content is available, this is no longer a supported browser by the manufacturer, and no attention is being given to having IE work well here.