Skill Views are what you see when you visit spruce.bot. They are top level views, comprised of (Card|List|Form|etc)Views, and controlled by SkillViewControllers
. Every skill gets a RootSkillViewController
that is loaded by the Skill's namespace. A Skill can have as many Skill Views (and Views) as desired.
# Create a new Skill View or View
spruce create.view
# Preview your views with live reload
spruce watch.views
Note: You must register your skill before being able to publish your Skill Views.
This is your primary view accessible by your Skill's namespace. For example, the Adventure
skill is accessible via https://adventure.spruce.bot. You should start each skill by creating your RootViewController.
Make sure you have logged in and registered your skill first!
spruce watch.views
Your views will be incrementally built as you make changes to source.
- Run
spruce create.test
- Select
AbstractSpruceFixtureTest
- Or select your Skill's primary AbstractTest if created
- Clear out the existing tests
- Add your first failing test
- Make sure your namespace is correct
- Change
adventure
to whatever your namespace is.
@test()
protected static async canRenderRootSkillView() {
const vc = this.views.Controller('adventure.root', {})
}
- Run
spruce create.view
- Create your
RootViewController
Your first test should be passing minus a type issue. Lets bring it home!
@test()
protected static async canRenderRootSkillView() {
const vc = this.views.Controller('adventure.root', {})
this.render(vc)
}
Your RootViewController should always successfully render. If this test ever fails, you have problems.
These libraries make failing tests easy!
- vcAssert
- General catch all. Everything used to be here, but they are starting to be extracted into new assertion tools.
- formAssert
- listAssert
- buttonAssert
The vcAssert
is the primary mechanism for building failing tests. For example, you may want to ensure your RootViewController
renders 2 Cards
.
This will involve moving the instantiation of your vc to the beforeEach
and then using vcAssert
to assert that your vc renders 2 cards.
import { vcAssert } from "@sprucelabs/heartwood-view-controllers";
import { AbstractSpruceFixtureTest } from "@sprucelabs/spruce-view-plugin";
import RootSkillViewController from "../../skillViewControllers/Root.svc";
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
private static vc: RootSkillViewController;
protected static async beforeEach() {
await super.beforeEach();
this.vc = this.views.Controller("adventure.root", {});
}
@test()
protected static canRenderRootSkillView() {
const model = this.vc.render();
assert.isTruthy(model);
}
@test()
protected static renders2Cards() {
vcAssert.assertSkillViewRendersCards(this.vc, ["profile", "equip"]);
}
@test()
protected static async requiresLogin() {
await vcAssert.assertLoginIsRequired(this.vc);
}
@test()
protected static canGetPofileCard() {
const cardVc = vcAssert.assertSkillViewRendersCard(this.vc, "profile");
assert.isEqual(cardVc, this.vc.getProfileCardVc());
}
@test()
protected static canGetEquipCard() {
const cardVc = vcAssert.assertSkillViewRendersCard(this.vc, "equip");
assert.isEqual(cardVc, this.vc.getEquipCardVc());
}
@test()
protected static async redirectsToAddOrganizationOnLoadIfNoCurrentOrganization() {
let wasHit = false;
await this.views
.getRouter()
.on("did-redirect", () => {
wasHit = true;
});
await this.views.load(this.vc);
assert.isTrue(wasHit);
}
}
Instantiate 2 Cards
in the constructor of your vc and render them in your vc's render
.
import {
AbstractSkillViewController,
CardViewController,
ViewControllerOptions,
} from "@sprucelabs/heartwood-view-controllers";
export default class RootSkillViewController extends AbstractSkillViewController {
public static id = "root";
private profileCardVc: CardViewController;
private equipCardVc: CardViewController;
public constructor(options: ViewControllerOptions) {
super(options);
this.profileCardVc = this.ProfileCardVc();
this.equipCardVc = this.EquipCardVc();
}
private EquipCardVc() {
return this.views.Controller("card", {
id: "equip",
header: {
title: "My great card 2!",
},
body: {
isBusy: true,
},
});
}
private ProfileCardVc() {
return this.views.Controller("card", {
id: "profile",
header: {
title: "My great card!",
},
body: {
isBusy: true,
},
});
}
public getProfileCardVc() {
return this.profileCardVc;
}
public getEquipCardVc() {
return this.equipCardVc;
}
public render(): SpruceSchemas.HeartwoodViewControllers.v2021_02_11.SkillView {
return {
layouts: [
{
cards: [this.profileCardVc.render()],
},
{
cards: [this.equipCardVc.render()],
},
],
};
}
}
Using @fake.login()
you can set a client all fixtures will share that make api request. This will also be the client returned from this.connectToApi()
in your views.
//test
@fake.login()
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@seed('organizations', 1)
protected static async beforeEach() {
await super.beforeEach();
this.client = fake.getClient();
this.vc = this.views.Controller("adventure.root", {});
}
@test()
protected static async trySomethingAsPrimaryPerson() {
await this.bootAndLoad()
...
}
@test()
protected static async tryAsSomeoneNotPartOfOrg() {
await this.views.loginAsDemoPerson(DEMO_NUMBER_OUTSIDER)
// skill will now boot and load with DEMO_NUMBER_OUTSIDER for this test
await this.bootAndLoad()
...
}
protected static async bootAndLoad() {
await this.bootSkill()
await this.views.load(this.vc)
}
}
//production
class RootSkillviewController extends AbstractSkillViewController {
public async load(options: SkillViewControllerLoadOptions) {
const client = await this.connectToApi();
const { auth } = await client.emitAndFlattenResponses("whoami::v2020_01_10");
this.log(person.phone)
//first test logs DEMO_NUMBER_ROOT_SVC
//second test logs DEMO_NUMBER_OUTSIDER
}
}
//test
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
protected static vc: SpyRootSkillView
protected static async beforeEach() {
await super.beforeEach()
this.views.setController('adventure.root', SpyRootSkillView)
this.vc = this.views.Controller('adventure.root', {}) as SpyRootSkillView
}
@test()
protected static async rendersList() {
const listVc = listAssert.cardRendersList(this.equipCardVc)
listAssert.listRendersRow(vc, 'no-entries')
listVc.addRow({...})
listVc.addRow({...})
listVc.addRow({ id: location.id, ...})
await interactor.clickInRow(vc, 2, 'edit')
await interactor.clickInRow(vc, location.id, 'edit')
}
protected static get equipCardVc() {
return this.vc.equipCardVc
}
}
//for accessing the list vc
class SpyRootSkillView extends RootSkilLViewController {
public equipCardVc: CardViewController
public equipmentListVc: ListViewController
}
//production
class RootSkillviewController extends AbstractSkillViewController {
protected equipCardVc: CardViewController
protected equipmentListVc: ListViewController
public constructor(options: ViewControllerOptions) {
super(options)
this.equipmentListVc = this.Controller('list', {...})
this.equipCardVc = this.Controller('card', ...)
}
}
//test
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@test()
protected static async confirmsBeforeSaving() {
const formVc = this.vc.getFormVc();
formVc.setValue("name", "Haircut");
const confirmVc = await vcAssert.assertRendersConfirm(this.vc, () =>
interactor.submitForm(formVc)
);
await confirmVc.accept();
const match = await this.Store("services").findOne({});
assert.isEqual(match.name, "Haircut");
}
@test()
protected static async rejectingConfirmDoesNotSave() {
const formVc = this.vc.getFormVc();
await formVc.setValue("name", "Haircut");
const confirmVc = await vcAssert.assertRendersConfirm(this.vc, () =>
interactor.submitForm(formVc)
);
await confirmVc.reject();
const match = await this.Store("services").findOne({});
assert.isNotEqual(match.name, "Haircut");
}
}
//production
class RootSkillviewController extends AbstractSkillViewController {
public constructor(options: ViewControllerOptions) {
super(options);
this.formCardVc = this.FormCardVc();
}
private FormVc() {
return this.views.Controller(
"form",
buildForm({
id: "service",
schema: serviceSchema,
onSubmit: this.handleSubmit.bind(this),
sections: [
{
fields: [
{
name: "name",
hint: "Give it something good!",
},
"duration",
],
},
],
})
);
}
private FormCardVc() {
return this.views.Controller("card", {
id: "service",
header: {
title: "Create your service!",
},
body: {
sections: [
{
form: this.formVc.render(),
},
],
},
});
}
private async handleSubmit({ values }: SubmitHandler<ServiceSchema>) {
const confirm = await this.confirm({ message: "You ready to do this?" });
if (confirm) {
await this.createService(values);
}
}
public getFormVc() {
return this.formVc;
}
}
A card with a list that is wicked easy to use and cuts out a ton of reduntant work!
Make sure you load
your Active Record Card for it to show any results!
//test
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@test()
protected static async rendersActiveRecordCard() {
const vc = this.views.Controller("my-skill.root", {});
const activeVc = vcAssert.assertSkillViewRendersActiveRecordCard(vc);
assert.isEqual(vc.getActiveRecordCard(), activeVc);
await this.views.load(vc);
assert.isTrue(activeVc.getIsLoaded());
}
}
//production
export default class RootSkillViewController extends AbstractViewController<Card> {
public constructor(options: ViewControllerOptions) {
super(options);
this.activeRecrodCardVc = this.ActiveRecordVc();
}
private ActiveRecordVc() {
return this.views.Controller(
"activeRecordCard",
buildActiveRecordCard({
header: {
title: "Your locations",
},
eventName: "list-locations::v2020_12_25",
payload: {
includePrivateLocations: true,
},
responseKey: "locations",
rowTransformer: (location) => ({ id: location.id, cells: [] }),
})
);
}
public load(options: SkillViewControllerLoadOptions) {
const organization = await options.scope.getCurrentOrganization();
this.activeRecordCardVc.setTarget({ organizationId: organization.id });
}
public getActiveRecordCardVc() {
return this.activeRecordCardVc;
}
}
Scoping experience to a specific organization or location.
By default, you will be scoped to your latest organization and location.
Learn more here.
//test
@login()
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
protected static async beforeEach() {
await super.beforeEach();
}
@test()
protected static async redirectsWhenNoCurrentOrg() {
let wasHit = false;
await vcAssert.assertActionRedirects({
router: this.views.getRouter(),
action: () => this.views.load(this.vc),
destination: {
id: "organization.add",
},
});
}
@test()
@seed("organization", 1)
protected static async doesNotRedirectWhenCurrentOrg() {
const organization = await this.fakedOrganizations[0]
//this is optional, the current org defaults to the newest added
//this.views.getScope().setCurrentOrganization(organization.id)
await vcAssert.assertActionDidNotRedirect({
router: this.views.getRouter(),
action: () => this.views.load(this.vc),
});
assert.isEqualDeep(this.vc.currentOrg, organization);
}
@test()
@seed("organizations", 3)
protected static async usesOrgFromScope() {
// since scope loads the newest org by default, we can set
// it back to the first org to test our productions code
const [organizations] = await this.fakedOrganizations
this.views.getScope().setCurrentOrganization(organization.id);
let wasHit = false;
await this.views.load(this.vc);
assert.isEqualDeep(this.vc.currentOrg, organization);
}
}
//production
class RootSkillviewController extends AbstractSkillViewController {
public async load(options: SkillViewControllerLoadOptions) {
const organization = await options.scope.getCurrentOrganization();
if (!organization) {
await options.router.redirect("organization.add" as any);
return;
}
this.currentOrganization = organization;
this.profileCardVc.setRouter(options.router);
this.profileCardVc.setIsBusy(false);
}
}
//test
@login()
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@test()
protected static rendersStats() {
vcAssert.assertRendersStats(this.vc.getCardVc());
}
@test()
@seed("organization", 1)
protected static async rendersExpectedStatsAfterLoad() {
await this.bootAndLoad();
}
private static async bootAndLoad() {
await this.bootSkill();
await this.views.load(this.vc);
}
}
//production
class RootSkillViewController extends AbstractSkillViewController {
public constructor(options: ViewControllerOptions) {
super(options);
this.cardVc = this.CardVc();
}
public async load(options: SkillViewControllerLoadOptions) {}
}
It is important that you test the graceful handling of failed requests on save. Use the eventMocker
to make events throw so you can gracefully handle them!
//test
@login()
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@test()
protected static async showsErrorWhenSavingFails() {
await eventMocker.makeEventThrow('create-organization::v2020_01_01')
const formVc = this.vc.getFormVc()
formVc.setValues({...})
await vcAssert.assertRenderAlert(this.vc, () => interactor.submitForm(formVc))
}
@test()
protected static async savesOrgWhenSubmittingForm() {
const formVc = this.vc.getFormVc()
await formVc.setValues({...})
await vcAssert.assertRendersSuccessAlert(this.vc, () => interactor.subimForm(formVc))
...
}
}
//production
class RootSkillviewController extends AbstractSkillViewController {
public constructor(options: SkillViewControllerOptions) {
super(options)
this.formVc = this.FormVc()
}
private FormVc() {
return this.views.Controller('form', buildForm({
...,
onSubmit: this.handleSubmit.bind(this)
}))
}
private async handleSubmit() {
const values = this.formVc.getValues()
try {
const client = await this.connectToApi()
await client.emitAndFlattenResponses('create-organization::v2020_01_01', {
payload: values
})
await this.alert({ message: 'You did it!!', style: 'success' })
} catch (err: any) {
await this.alert({ message: err.message })
}
}
}
//test
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@test()
protected static rendersToolBelt() {
tt.assertDoesNotRenderToolBelt(this.vc)
await this.views.load(this.vc)
const toolBeltVc = vcAssert.assertRendersToolBelt(this.vc)
const toolVc = vcAssert.assertToolBeltRendersTool(this.vc, 'edit')
assert.isTruthy(toolVc, 'Your ToolBelt does not render a tool with a properly rendered CardVc.')
}
@test()
protected static async addsTitleSubTitleCard() {
// check if a tool is an instance of a specific Class
const toolVc = vcAssert.assertToolInstanceOf(
this.toolBeltVc,
'title',
EventTitleCardViewController
)
// check if tool is accessible off the parent view controller
assert.isEqual(toolVc, this.vc.getTitleCardVc())
}
}
//production
class RootSkillviewController extends AbstractSkillViewController {
public constructor(options: SkillViewControllerOptions) {
super(options)
this.toolBeltVc = this.ToolBelt()
}
private ToolBelt() {
return this.views.Controller('toolBelt', {
...,
})
}
public async load(options: SkillViewControllerLoadOptions) {
this.toolBeltVc.addTool({
id: 'edit',
lineIcon: 'globe',
card: this.views.Controller('card', { ... })
})
}
public renderToolBelt() {
return this.toolBeltVc.render()
}
}
//test
export default class RootSkillViewControllerTest extends AbstractSpruceFixtureTest {
@test()
protected static redirectsOnSelectLocation() {
const locationsCardVc = this.vc.getLocationsCardVc()
const location = await this.views.getScope().getCurrentLocation()
await vcAssert.assertActionRedirects({
router: this.views.getRouter(),
action: () =>
interactor.clickButtonInRow(
locationsCardVc.getListVc(),
location.id,
'edit'
),
destination: {
id: 'locations.root',
args: {
locationId: location.id,
},
},
})
}
}
//production
class RootSkillviewController extends AbstractSkillViewController {
public constructor(options: SkillViewControllerOptions) {
super(options)
this.locationsCardVc = this.ActiveRecordCardVc()
}
public async load(options: SkillViewControllerLoadOptions) {
this.router = options.router
await this.locationsCardVc.load()
}
private activeRecordCardVc() {
return this.views.Controller('activeRecordCard', buildActiveRecordCard({
...,
rowTransformer: (location) => ({
id: location.id
cells: [
{
text: {
content: location.name
},
},
{
button: {
id: 'edit',
onClick: async () => {
await this.router?.redirect('locations.root', {
locationId: location.id
})
}
}
}
]
})
}))
}
public getLocationsCardVc() {
return this.locationsCardVc
}
}
You can inspect your bundled view controller source by setting this in your skill's env:
VIEW_PROFILER_STATS_DESTINATION_DIR=/path/to/a/dir/that/exists
This will write a file called stats.json
at that destination. You can upload it to https://webpack.github.io/analyse/#modules to see what is being included.
- Look at locations skill
- Look at forms skill
- Use
spruce watch.views
and then visit thehttps://developer.spruce.bot/#views/heartwood.watch
- Checkout the
vcAssert.test.ts
inheartwood-view-controllers
- Give your buttons, list rows, and cards ids and assert against them
- vcAssert.assertListRendersRow(rowVc, service.id)
- interactor.clickButtonInFooter(cardVc, 'edit')