To override Locode's built-in Form UI you can add custom Vue components
to your Host Project /wwwroot folder at /modules/locode/components/*.mjs
using the naming conventions below:
Component Name | Description |
---|---|
New{Table} |
Custom Create Form UI |
Edit{Table} |
Custom Update Form UI |
The chinook.locode.dev demo does this to create a custom Form UI for creating and
editing Albums by registering NewAlbums
in NewAlbums.mjs
and EditAlbums
component in EditAlbums.mjs
which are used to render Chinook's custom Create Album form
to update its Albums
table.
Built-in App functionality​
JavaScript Libraries​
Your custom components can utilize built in libraries embedded in ServiceStack.dll where they will have access to the latest Vue 3 reactive fx, @servicestack/client client library and Vue 3 Tailwind Component library which they can import by package name, e.g:
import { ref } from "vue"
import { useClient } from "@servicestack/vue"
import { humanify } from "@servicestack/client"
Static Analysis​
As all package dependencies are written in TypeScript you can install them as dev dependencies to get static analysis from its TypeScript definitions at dev time:
npm install -D vue
npm install -D @servicestack/client
npm install -D @servicestack/vue
Your components can access your Apps Typed DTOs directly from the ES6 Module DTO endpoint at /types/mjs
, e.g:
import { CreateAlbums } from "/types/mjs"
App functionality​
Your components access to most App functionality via the injected dependencies for functionality defined in Locode's app.mjs:
const app = inject('app') // App for customizing Vue App, register components, providers, plugins, etc
const client = inject('client') // JsonServiceClient for API Calls
const server = inject('server') // AppMetadata (metadata for your Server App and APIs)
const store = inject('store') // Locode's Reactive object model
const routes = inject('routes') // usePageRoutes() Reactive store to manage its SPA routing
const breakpoints = inject('breakpoints') // useBreakpoints() Reactive store to Tailwind responsive breakpoints
Most of which creates instance of common library features in core.mjs that are documented at api.locode.dev/modules/locode.html.
You're also not limited with what's in Locode, with full access to JavaScript Modules you can import external 3rd Party packages the same way you import built-in packages.
Code walkthrough​
Your components will have full control to implement their desired functionality how they want, which can get a lot of integrated functionality for free by leveraging the Vue Component library, e.g. this custom component uses:
- ModalDialog - to load our custom Create Albums Form component in a Modal Dialog
- ErrorSummary - to display any non-contextual summary API errors
- TextInput - to create a validation bound form for the
CreateAlbums
Title property - LookupInput - to create a Lookup input to select an Artist for the
CreateAlbums
ArtistId property
Whilst <SubmitAlbumButton>
is an example of using a shared component in
SubmitAlbumButton.mjs.
import { ref } from "vue"
import { useClient, useMetadata } from "@servicestack/vue"
import { CreateAlbums } from "/types/mjs"
export const NewAlbums = {
template:/*html*/`
<ModalDialog @done="done" sizeClass="">
<div class="album-form relative flex flex-col">
<ErrorSummary except="title,artistId"/>
<form @submit.prevent="submit" class="m-4 shadow-md rounded-full w-96 h-96 flex justify-center items-center">
<div class="flex flex-col justify-center items-center text-center">
<h1 class="text-3xl font-medium text-rose-500 mb-4">New Album</h1>
<fieldset>
<TextInput id="title" v-model="request.title" label="" placeholder="Album Title" class="mb-3" />
<LookupInput id="artistId" v-model="request" label="" placeholder="Select Artist"
:input="lookupProp.input" :metadataType="dataModelType" class="mb-3" />
<SubmitAlbumButton />
</fieldset>
</div>
</form>
</div>
</ModalDialog>
`,
props: ['type'],
emits: ['done','save'],
setup(props, { emit }) {
const client = useClient()
const { typeOf } = useMetadata()
const dataModelType = typeOf("Albums")
const lookupProp = dataModelType.properties.find(x => x.name === 'ArtistId')
const request = ref(new CreateAlbums())
/** @param {Event} e */
async function submit(e) {
const form = e.target
const api = await client.apiForm(new CreateAlbums(), new FormData(form))
if (api.succeeded) {
emit('save', api.response)
}
}
function done() {
emit('done')
}
return { request, lookupProp, dataModelType, submit, done }
}
}
The only integration needed to communicate back with Locode's
AutoQueryGrid component is to emit done
when the form is dismissed without changes or emit save
if changes are made to
refresh the AutoQueryGrid resultset to see the latest changes.
Invoking APIs with useClient()
APIs will propagate any error information from any declarative validation attributes into validation-aware components which alleviates us from needing to perform any manual validation ourselves.
When registered this custom component replaces Locode's Auto Form UI with a custom Create Album Form:
That when submitting an empty form will trigger the contextual validation errors to appear:
As enforced by the Declarative Validation rules on the CreateAlbums
AutoQuery CRUD DTO its calling:
[Route("/albums", "POST"), Tag(Tags.Media)]
public class CreateAlbums
: IReturn<IdResponse>, IPost, ICreateDb<Albums>
{
[ValidateNotEmpty]
public string Title { get; set; }
[ValidateGreaterThan(0)]
public long ArtistId { get; set; }
}
Custom Edit Form​
The custom EditAlbums
form has a very similar implementation the NewAlbums
implementation above other than populating the Input components with
the existing Album's values, accessible via the model property.
import { useClient, useMetadata } from "@servicestack/vue"
import { ref } from "vue"
import { UpdateAlbums } from "/types/mjs"
export const EditAlbums = {
template:/*html*/`
<ModalDialog @done="done" sizeClass="">
<div class="album-form relative flex flex-col">
<ErrorSummary except="title,artistId" />
<form @submit.prevent="submit" class="m-4 shadow-md rounded-full w-96 h-96 max-w-96 flex justify-center items-center">
<div class="flex flex-col justify-center items-center text-center">
<h1 class="text-3xl font-medium text-rose-500 mb-4">Edit Album {{ request.albumId }}</h1>
<fieldset>
<input type="hidden" name="albumId" :value="request.albumId">
<TextInput id="title" v-model="request.title" label="" placeholder="Album Title" class="mb-3" />
<LookupInput id="artistId" v-model="request" label="" placeholder="Select Artist"
:input="lookupProp.input" :metadataType="dataModelType" class="mb-3" />
<SubmitAlbumButton />
</fieldset>
</div>
</form>
</div>
</ModalDialog>
`,
props: ['model','type','deleteType'],
emits: ['done','save'],
setup(props, { emit }) {
const client = useClient()
const { typeOf } = useMetadata()
const dataModelType = typeOf("Albums")
const lookupProp = dataModelType.properties.find(x => x.name === 'ArtistId')
const request = ref(new UpdateAlbums(props.model))
/** @param {Event} e */
async function submit(e) {
const form = e.target
const api = await client.apiForm(new UpdateAlbums(), new FormData(form))
if (api.succeeded) {
emit('save', api.response)
}
}
function done() {
emit('done')
}
return { request, lookupProp, dataModelType, submit, done }
}
}
If applicable deleteType will be populated with an API to Delete Albums that the current user has authorization to access, should you wish to implement delete functionality.
Then to perform the updates we just need to call an Update Albums API, which for Chinook is called PatchAlbums
:
[Route("/albums/{AlbumId}", "PATCH"), Tag(Tags.Media)]
public class PatchAlbums
: IReturn<IdResponse>, IPatch, IPatchDb<Albums>
{
public long AlbumId { get; set; }
public string Title { get; set; }
public long ArtistId { get; set; }
}
Which is all that's need to implement our custom Edit Albums Form:
To minimize code duplication both custom forms makes use of a shared SubmitAlbumButton.mjs component, defined as:
export const SubmitAlbumButton = {
template:`
<button type="submit" class="inline-flex items-center p-3 border border-transparent rounded-full shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">
<svg class="h-8 w-8" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" preserveAspectRatio="xMidYMid meet" viewBox="0 0 48 48">
<g fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="4">
<path stroke-linecap="round" d="M24 44C12.954 44 4 35.046 4 24S12.954 4 24 4s20 8.954 20 20"/>
<path d="M20 24v-6.928l6 3.464L32 24l-6 3.464l-6 3.464V24Z"/><path stroke-linecap="round" d="M37.05 32v10M42 36.95H32"/>
</g>
</svg>
</button>`
}
Custom Locode Home Page​
Next we'll look at how we can create a custom Home page by overriding Locode's
existing Welcome.mjs
component.