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.
Method | Returns | Description |
---|---|---|
CalendarEventViewController | Controller | A 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.
Method | Returns | Description |
---|---|---|
render() | string | Renders the view and returns the string ‘go-team’. |
BookSkillViewController (constructor) | BookSkillViewController | Initializes a new instance of BookSkillViewController by extending AbstractSkillViewController . |
AbstractViewController - The class your views can extend to have access to helpful properties.
Method | Returns | Description |
---|---|---|
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.
Method | Returns | Description |
---|---|---|
activeRecordCard | ActiveRecordCardViewController | Provides the active record card view controller. |
'active-record-card' | ActiveRecordCardViewController | Maps the active record card route to the ActiveRecordCardViewController . |
ActiveRecordListViewController - A list that makes loading, searching, and paging through database record a breeze.
Method | Returns | Description |
---|---|---|
activeRecordList | ActiveRecordListViewController | Provides the active record list view controller. |
'active-record-list' | ActiveRecordListViewController | Maps the active record list route to the ActiveRecordListViewController . |
AutocompleteInputViewController - Turns a text input into an autocomplete input.
Method | Returns | Description |
---|---|---|
autocompleteInput | AutocompleteInputViewController | Provides the autocomplete input view controller. |
'autocomplete-input' | AutocompleteInputViewController | Maps the autocomplete input route to the AutocompleteInputViewController . |
BigFormViewController - A form that renders one field at a time with customizable transitions between questions.
Method | Returns | Description |
---|---|---|
bigForm | BigFormViewController | Provides the big form view controller. |
'big-form' | BigFormViewController | Maps the big form route to the BigFormViewController . |
ButtonBarViewController - A strip of buttons that supports selection and deselection.
Method | Returns | Description |
---|---|---|
buttonBar | ButtonBarViewController | Provides the button bar view controller. |
'button-bar' | ButtonBarViewController | Maps the button bar route to the ButtonBarViewController . |
ButtonGroupViewController - An array of buttons that supports selected and deselected states.
Method | Returns | Description |
---|---|---|
buttons | ButtonGroupButton[] | Retrieves the array of button group buttons. |
selectedButtonIds | string[] | Retrieves the array of selected button IDs. |
selectionChangeHandler | SelectionChangeHandler | Retrieves the selection change handler function, if any. |
CalendarViewController - A calendar that supports day and month views (more soon)!
Method | Returns | Description |
---|---|---|
model | Omit<CalendarOptions, 'events'> | The calendar options model excluding the events property. |
vcIdsByEventType | Record<string, string> | A record mapping event types to view controller IDs. |
vcsById | Record<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!
Method | Returns | Description |
---|---|---|
AbstractCalendarEventViewController (constructor) | AbstractCalendarEventViewController | Initializes a new instance of AbstractCalendarEventViewController by extending AbstractViewController<Event> and implementing CalendarEventVc . |
CardViewController - The building block of every Skill View!
Method | Returns | Description |
---|---|---|
FeedbackCardViewController (constructor) | FeedbackCardViewController | Initializes a new instance of FeedbackCardViewController . |
FamilyMemberFormCardViewController (constructor) | FamilyMemberFormCardViewController | Initializes a new instance of FamilyMemberFormCardViewController . |
'eightbitstories.feedback-card' | FeedbackCardViewController | Maps the feedback card route to the FeedbackCardViewController . |
'eightbitstories.family-member-form-card' | FamilyMemberFormCardViewController | Maps 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!
Method | Returns | Description |
---|---|---|
CountdownTimerViewController (constructor) | CountdownTimerViewController | Initializes a new instance of CountdownTimerViewController . |
CountdownTimerViewControllerOptions | CountdownTimerViewControllerOptions | Options for configuring a CountdownTimerViewController . |
navigation | NavigationViewController | Provides the navigation view controller. |
'countdown-timer' | CountdownTimerViewController | Maps the countdown timer route to the CountdownTimerViewController . |
'progress-navigator' | ProgressNavigatorViewController | Maps the progress navigator route to the ProgressNavigatorViewController . |
FeedViewController - A chat component for handling coversations!
Method | Returns | Description |
---|---|---|
FeedViewController (constructor) | FeedViewController | Initializes a new instance of FeedViewController . |
FeedViewControllerOptions | FeedViewControllerOptions | Options for configuring a FeedViewController . |
map | MapViewController | Provides the map view controller. |
feed | FeedViewController | Provides the feed view controller. |
navigation | NavigationViewController | Provides the navigation view controller. |
FormBuilderViewController - A component for building custom forms!
FormViewController - A form comprised of inputs (or a list with inputs)!
ListCellViewController - The cells that build a row.
Method | Returns | Description |
---|---|---|
CellVc(index: number) | ListCellViewController | Retrieves the list cell view controller at the specified index. |
assert.isTrue(value: boolean) | void | Asserts that the provided value is true. |
ListRowViewController - Rows that build a list.
Method | Returns | Description |
---|---|---|
rowVc | ListRowViewController | The list row view controller instance associated with the cell input key down event. |
key | KeyboardKey | The keyboard key that was pressed during the cell input key down event. |
ListViewController - A List based on rows and cells.
Method | Returns | Description |
---|---|---|
'form-builder-card' | FormBuilderCardViewController | Maps the form builder card route to the FormBuilderCardViewController . |
list | ListViewController | Provides the list view controller. |
toolBelt | ToolBeltViewController | Provides the tool belt view controller. |
MapViewController - A customizable map with pin and navigation support.
Method | Returns | Description |
---|---|---|
MapViewController (constructor) | MapViewController | Initializes a new instance of MapViewController . |
MapViewControllerOptions | MapViewControllerOptions | Options for configuring a MapViewController . |
LoginViewController (constructor) | LoginViewController | Initializes a new instance of LoginViewController . |
NavigationViewController - Customize the navigation. Currently render inside the ControlBar.
Method | Returns | Description |
---|---|---|
feed | FeedViewController | Provides the feed view controller. |
navigation | NavigationViewController | Provides the navigation view controller. |
'countdown-timer' | CountdownTimerViewController | Maps the countdown timer route to the CountdownTimerViewController . |
PolarAreaViewController - A polar area chart.
Method | Returns | Description |
---|---|---|
model | PolarArea | Retrieves the model for the Polar Area view controller. |
PolarAreaViewController (constructor) | PolarAreaViewController | Initializes a new instance of PolarAreaViewController by extending AbstractViewController<PolarArea> . |
ProgressNavigatorViewController - Renders at the top of the screen to track progress through any process.
Method | Returns | Description |
---|---|---|
progressNavigator | ProgressNavigatorViewController | Provides the progress navigator view controller. |
WithProgressSkillView (constructor) | WithProgressSkillView | Initializes a new instance of WithProgressSkillView with the provided view controller options. |
ProgressViewController - A progress indicator with an optional message.
Method | Returns | Description |
---|---|---|
stats | StatsViewController | Provides the stats view controller. |
progress | ProgressViewController | Provides the progress view controller. |
ratings | RatingsViewController | Provides the ratings view controller. |
RatingsViewController - A ratings component to gauge sentiment or against a scale.
Method | Returns | Description |
---|---|---|
RatingsInputComponentIcon | RatingsInputComponentIcon | Retrieves the icon component used in the ratings input. |
RatingsViewControllerOptions | RatingsViewControllerOptions | Options for configuring a RatingsViewController . |
ControllingARatingsViewTest (constructor) | ControllingARatingsViewTest | Initializes a new instance of ControllingARatingsViewTest by extending AbstractViewControllerTest . |
vc | RatingsViewController | The static instance of RatingsViewController used in the test. |
SwipeViewController - A version of a card where sections are rendered as a swipe view.
Method | Returns | Description |
---|---|---|
Vc(options: SwipeViewControllerOptions) | SwipeCardViewController | Creates and returns a new instance of SwipeCardViewController with the provided options. |
StatsViewController - Render numbers with labels with some nice animations.
Method | Returns | Description |
---|---|---|
'active-record-list' | ActiveRecordListViewController | Maps the active record list route to the ActiveRecordListViewController . |
stats | StatsViewController | Provides the stats view controller. |
progress | ProgressViewController | Provides the progress view controller. |
TalkingSprucebotViewController - Sprucebot animation with typing text. Great for storytelling!
Method | Returns | Description |
---|---|---|
talkingSprucebot | TalkingSprucebotViewController | Provides the talking Sprucebot view controller. |
'talking-sprucebot' | TalkingSprucebotViewController | Maps the talking Sprucebot route to the TalkingSprucebotViewController . |
ToolBeltViewController - Holds an extra set of cards that hide when not in use!
Method | Returns | Description |
---|---|---|
list | ListViewController | Provides the list view controller. |
toolBelt | ToolBeltViewController | Provides 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.
- Subdomain:
https://{skillNamespace}.spruce.bot
(will render yourRootSkillViewController
) - 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()
. Becauserender()
is called beforeload()
, I had to make sure thatthis.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
andresponseKey
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 theActiveRecordCard
. 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 aSpy
test double to expose theActiveRecordCard
’sgetListVc()
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 inMyCardViewController
. 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:
- Setting
activeRecordCardVc
toprotected
so thatSpyMyCard
can access it. - 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:
- Moved a lot of things to the
beforeEach()
to simplify the tests- The
organizationId
- The
familyMembers
return from the event - The
lastListFamilyMembersTarget
from the event
- The
- Created a
load()
method to pass theorganizationId
toload()
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 ofViewControllers
. That’ll be added for you.
Note: It is helpful to add the name of the
ViewModel
being rendered. Examples: If you render aCard
, end your name inCard
. If you render aForm
, end your name inForm
.
Note: Don’t add
Dialog
to name of yourViewController
. Because aCardViewController
can be rendered in a dialog or in aSkillView
, 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. aSpy
(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 aload()
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 theconstructor
.
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 callthis.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:
- I created a
Spy
that extendsMyCardViewController
and added a public property calledwasHandleDidGenerateStoryCalled
. - Did away with the local property
wasDidGenerateStoryCalled
on the test class and replaced it with a getter that returns theSpy
’s property. - 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:
- You construct your form using the
buildForm
utility for better typing. - The
FormViewController
interface is a generic, so it’ll take the type of yourSchema
to enable advanced typing. - You render your
Form
into theform
property of yourCardSection
.
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 aSpy
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
isprivate
. You can make itprotected
inMyCardViewController
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:
- Add the fields to your
Schema
. - Add the fields as a
Section
to yourForm
.
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 aformVc
getter to cut down on duplication.
Production 1: Render an AutocompleteInput in your form
This is another 2 parter:
- Construct an
AutocompleteInputViewController
and track it on yourViewController
. - Update your
FormSection
to be the “expanded” type, which is an object withfield
andvc
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:
- Use the
autocompleteAssert
utility to assert that theAutocompleteInputViewController
shows suggestions when therenderedValue
is changed. - Update your
Spy
to expose theAutocompleteInputViewController
withgetAutocompleteVc()
.
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’ inMyCardViewController
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 inbeforeEach
, 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 yourMasterSkillView
.
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:
- Construct a
MasterSkillView
- Pass it at least one
entity
(usingbuildMasterListEntity(...)
) and put in gibberish for now. - Render the
MasterSkillView
by updating therender()
method in yourRootSkillView
.
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))
Navigation
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:
- 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.
- 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
- Install the module that holds the plugin:
yarn add {packageName}
- Create the plugin:
spruce create.view.plugin
- 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()
}
}