Frontend Quickstart =================================== VST utils framework uses Vue ecosystem to render frontend. The quickstart manual will guide you through the most important steps to customize frontend features. App installation and setting up described in - :doc:`Backend Section ` of this docs. There are several stages in vstutils app: 1. Before app started: * `checkCacheVersions()` checks if app version has been changed since last visit and cleans all cached data if so; * loading open api schema from backend. Emits 'openapi.loaded' signal; * loading all static files from `SPA_STATIC` in setting.py; * sets `AppConfiguration` from OpenAPI schema; 2. App started: * if there is centrifugoClient in settings.py connects it. To read more about centrifugo configuration check ":ref:`centrifugo`"; * downloading a list of available languages and translations; * `api.loadUser()` returns user data; * `ModelsResolver` creates models from schema, emits signal `models[${modelName}].created` for each created model and `allModels.created` when all models created; * `ViewConstructor.generateViews()` inits `View` fieldClasses and modelClasses; * `QuerySetsResolver` finds appropriate queryset by model name and view path; * `global_components.registerAll()` registers Vue `global_components`; * `prepare()` emits `app.beforeInit` with { app: this }; * initialize model with `LocalSettings`. Find out more about this in the section :ref:`localSettings`; * creates routerConstructor from `this.views`, emits 'app.beforeInitRouter' with { routerConstructor } and gets new VueRouter({this.routes}); * inits application `Vue()` from schema.info, pinia store and emits 'app.afterInit' with {app: this}; 3. Application mounted. There is a flowchart representing application initialization process (signal names have red font): .. mermaid:: graph TD Cached("checkCachedVersion()")--New App Version-->Clean("cleanAllCache()"); Cached--Same App Version-->Schema(Load Schema); Clean-->Cached Schema--'openapi.loaded'-->AppConfiguration AppConfiguration--Has Centrifugo options-->Centrifugo(Connect Centrifugo) AppConfiguration--No Centrifugo-->Translation Centrifugo-->Translation(Load translation,
load languages) Translation-->LoadUser("api.LoadUser()")-->ModelsResolver subgraph Models generation ModelsResolver--All Models Created-->B('allModels.created'):::classSignal ModelsResolver--Not All Models Created-->Create(Create Model) Create(Create Model)-->SignalBeforeInit("'models#91;modelName#93;.fields.beforeInit'"):::classSignal-->Fields(Create Fields) Fields(Create Fields)-->SignalAfterInit("'models#91;modelName#93;.fields.afterInit'"):::classSignal-->modelsmodelName("'models#91;modelName#93;.created'"):::classSignal modelsmodelName-->ModelsResolver end ViewConstructor("ViewConstructor.generateViews()")--'allViewsCreated'-->QuerySetResolver QuerySetResolver--finds approppriate querySet-->registerAll("global_components.registerAll()") registerAll--registers Vue global_components-->prepare prepare--'app.beforeInit'-->RouterConstuctor RouterConstuctor--'app.beforeInitRouter'-->D("new VueRouter()") D-->E(Vue Internationalization plugin - i18n) E-->F(Create new Vue Instance) linkStyle 3 stroke:red,color:red linkStyle 11 stroke:red,color:red linkStyle 12 stroke:red,color:red linkStyle 14 stroke:red,color:red linkStyle 16 stroke:red,color:red linkStyle 19 stroke:red,color:red linkStyle 20 stroke:red,color:red classDef classSignal stroke:#333,color:#f00; .. _frontend_customization: Frontend customization ---------------------- To use custom frontend in you project rename `vite.config.ts.default` to `vite.config.ts`. Every project based on vst-utils contains `index.ts` in `/frontend_src/` directory. This file is intended for your code. Run `yarn` command to install all dependencies. Then run `yarn devBuild` from root dir of your project to build static files. Output files will be built into `{AppName}/static/spa` directory. During vstutils installation through `pip` frontend code are being build automatically, so you may need to add `spa` directory to `.gitignore`. You can specify the `path` to the service worker and its `scope`. By default, these values are `'/service-worker.js'` and `'/'` respectively. The `defaultNotificationOptions` allows setting properties such as icon, title, or any other notification preferences for consistency across the app. Example of simple frontend entrypoint: .. sourcecode:: typescript import { initApp } from '@vstconsulting/vstutils'; initApp({ api: { url: new URL('/api/', window.location.origin).toString(), }, sw: { path: '/new-sw.js', scope: '/some-route', }, defaultNotificationOptions: { icon: '/static/icons/logo.svg', }, }); Operation views hooks --------------------- Function `hookViewOperation` can be used to execute some custom code before action execution. Action may be prevented if `prevent` is set to true on returned object. .. sourcecode:: typescript import { hookViewOperation, showConfirmationModal } from '@vstconsulting/vstutils'; hookViewOperation({ path: '/category/{id}/change_parent/', operation: 'execute', onBefore: async () => { const isConfirmed = await showConfirmationModal({ title: 'Are you sure?', text: 'Changing category parent is irreversible', confirmButtonText: 'Change', cancelButtonText: 'Cancel', }); return { prevent: !isConfirmed, }; }, }); .. _field-section: Field customization ------------------- 1. In `main.js` create new field by extending it from BaseField (or any other appropriate field) For example lets create a field that renders HTML h1 element with 'Hello World!` text: .. sourcecode:: javascript class CustomField extends spa.fields.base.BaseField { static get mixins() { return super.mixins.concat({ render(createElement) { return createElement('h1', {}, 'Hello World!'); }, }); } } Or render person's name with some prefix .. sourcecode:: javascript class CustomField extends spa.fields.base.BaseField { static get mixins() { return super.mixins.concat({ render(h) { return h("h1", {}, `Mr ${this.$props.data.name}`); }, }); } } 2. Register this field to `app.fieldsResolver` to provide appropriate field format and type to a new field .. sourcecode:: javascript const customFieldFormat = 'customField'; app.fieldsResolver.registerField('string', customFieldFormat, CustomField); 3. Listen for a appropriate `models[ModelWithFieldToChange].fields.beforeInit` signal to change field Format .. sourcecode:: javascript spa.signals.connect(`models[ModelWithFieldToChange].fields.beforeInit`, (fields) => { fields.fieldToChange.format = customFieldFormat; }); List of models and their fields is available during runtime in console at `app.modelsClasses` To change Filed behavior, create new field class with a desired logic. Let's say you need to send number of milliseconds to API, user however wants to type in number of seconds. A solution would be to override field's `toInner` and `toRepresent` methods. .. sourcecode:: javascript class MilliSecondsField extends spa.fields.numbers.integer.IntegerField { toInner(data) { return super.toInner(data) * 1000; } toRepresent(data) { return super.toRepresent(data)/1000; } } const milliSecondsFieldFormat = 'milliSeconds' app.fieldsResolver.registerField('integer', milliSecondsFieldFormat, MilliSecondsField); spa.signals.connect(`models[OneAllFields].fields.beforeInit`, (fields) => { fields.integer.format = milliSecondsFieldFormat; }); Now you have field that show seconds, but saves/receives data in milliseconds on detail view of AllFieldsModel. .. note:: If you need to show some warning or error to developer console you can use field `warn` and `error` methods. You can pass some message and it will print it with field type, model name and field name. Change path to FkField ---------------------- Sometime you may need to request different set of objects for FkField. For example to choose from only famous authors, create `famous_author` endpoint on backend and set FkField request path to `famous_author`. Listen for `app.beforeInit` signal. .. sourcecode:: javascript spa.signals.connect('app.beforeInit', ({ app }) => { app.modelsResolver.get('OnePost').fields.get('author').querysets.get('/post/new/')[0].url = '/famous_author/' }); Now when we create new post on `/post/` endpoint Author FkField makes get request to `/famous_author/` instead of `/author/`. It's useful to get different set of authors (that may have been previously filtered on backend). CSS Styling ----------- 1. Like scripts, css files may be added to `index.ts` .. sourcecode:: typescript import { initApp } from '@vstconsulting/vstutils'; import './style.css'; initApp({ api: { url: new URL('/api/', window.location.origin).toString(), }, }); Let's inspect page and find css class for our customField. It is `column-format-customField` and generated with `column-format-{Field.format}` pattern. 2. Use regular css styling to change appearance of the field. .. code-block:: css .column-format-customField:hover { background-color: orangered; color: white; } Other page elements are also available for styling: for example, to hide certain column set corresponding field to none. .. code-block:: css .column-format-customField { display: none; } Show primary key column on list ------------------------------- Every pk column has `pk-column` CSS class and hidden by default (using `display: none;`). For example this style will show pk column on all list views of `Order` model: .. sourcecode:: css .list-Order .pk-column { display: table-cell; } View customization ------------------- Listen for signal `"allViews.created"` and add new custom mixin to the view. Next code snippet depicts rendering new view instead of default view. .. sourcecode:: javascript spa.signals.once('allViews.created', ({ views }) => { const AuthorListView = views.get('/author/'); AuthorListView.mixins.push({ render(h) { return h('h1', {}, `Custom view`); }, }); }); Learn more about Vue `render()` function at `Vue documentation `_. It is also possible to fine tune View by overriding default computed properties and methods of existing mixins. For example, override breadcrumbs computed property to turn off breadcrumbs on Author list View .. sourcecode:: javascript import { ref } from 'vue'; spa.signals.once("allViews.created", ({ views }) => { const AuthorListView = views.get("/author/"); AuthorListView.extendStore((store) => { return { ...store, breadcrumbs: ref([]), }; }); }); Sometimes you may need to hide detail page for some reason, but still want all actions and sublinks to be accessible from list page. To do it you also should listen signal `"allViews.created"` and change parameter `hidden` from default `false` to `true`, for example: .. sourcecode:: javascript spa.signals.once('allViews.created', ({ views }) => { const authorView = views.get('/author/{id}/'); authorView.hidden = true; }); Changing title of the view -------------------------- To change title and string displayed in the breadcrumbs change `title` property of the view or method `getTitle` for more complex logic. .. sourcecode:: javascript spa.signals.once('allViews.created', ({ views }) => { const usersList = views.get('/user/'); usersList.title = 'Users list'; const userDetail = views.get('/user/{id}/'); userDetail.getTitle = (state) => (state?.instance ? `User: ${state.instance.id}` : 'User'); }); Page store ---------- Every page has store that can be accessed globally `app.store.page` or from page component using `this.store`. View method `extendStore` can be used to add custom logic to page's store. .. sourcecode:: javascript import { computed } from 'vue'; spa.signals.once('allViews.created', ({ views }) => { views.get('/user/{id}/').extendStore((store) => { // Override title of current page using computed value const title = computed(() => `Current page has ${store.instances.hength} instances`); async function fetchData() { await store.fetchData(); // Call original fetchData await callSomeExternalApi(store.instances.value); } return { ...store, title, fetchData, }; }); }); Overriding root component ------------------------- Root component of the application can be overridden using `app.beforeInit` signal. This can be useful for such things as changing layout CSS classes, back button behaviour or main layout components. Example of customizing sidebar component: .. sourcecode:: javascript const CustomAppRoot = { components: { Sidebar: CustomSidebar }, mixins: [spa.AppRoot], }; spa.signals.once('app.beforeInit', ({ app }) => { app.appRootComponent = CustomAppRoot; }); Translating values of fields ---------------------------- Values tha displayed by `FKField` of `ChoicesField` can be translated using standard translations files. Translation key must be defined as `:model:::`. For example: .. sourcecode:: python TRANSLATION = { ':model:Category:name:Category 1': 'Категория 1', } Translation of values can be taxing as every model on backend usually generates more than one model on frontend, To avoid this, add `_translate_model = 'Category'` attribute to model on backend. It shortens .. sourcecode:: python ':model:Category:name:Category 1': 'Категория 1', ':model:OneCategory:name:Category 1': 'Категория 1', ':model:CategoryCreate:name:Category 1': 'Категория 1', to .. sourcecode:: python ':model:Category:name:Category 1': 'Категория 1', For `FKField` name of the related model is used. And `fieldName` should be equal to `viewField`. .. _changing-actions-or-sublinks: Changing actions or sublinks ---------------------------- Sometimes using only schema for defining actions or sublinks is not enough. For example we have an action to make user a superuser (`/user/{id}/make_superuser/`) and we want to hide that action if user is already a superuser (`is_superuser` is `true`). `<${PATH}>filterActions` signal can be used to achieve such result. .. sourcecode:: javascript spa.signals.connect('filterActions', (obj) => { if (obj.data.is_superuser) { obj.actions = obj.actions.filter((action) => action.name !== 'make_superuser'); } }); 1. `<${PATH}>filterActions` recieves {actions, data} 2. `<${PATH}>filterSublinks` recieves {sublinks, data} Data property will contain instance's data. Actions and sublinks properties will contain arrays with default items (not hidden action or sublinks), it can be changed or replaced completely. .. _localSettings: LocalSettings ------------- This model's fields are displayed in the left sidebar. All data from this model saves in browser Local Storage. If you want to add another options, you can do it using `beforeInit` signal, for example: .. sourcecode:: javascript spa.signals.once('models[_LocalSettings].fields.beforeInit', (fields) => { const cameraField = new spa.fields.base.BaseField({ name: 'camera' }); // You can add some logic here fields.camera = cameraField; }) Store ----- There are three ways to store data: * userSettingsStore - saves data on the server. By default, there are options for changing language and a button to turn on/off the dark mode. Data to userSettingsStore comes from schema. * localSettingsStore - saves data in the browser Local Storage. This is where you can store your own fields, as described in :ref:`localSettings`. * store - stores current page data. To use any of this stores you need to run the following command: :code:`app.[storeName]`, for example: :code:`app.userSettingsStore`. .. note:: If you are accessing the userSettingsStore from within the component then you need to use :code:`this.$app` instead :code:`app`. From `app.store` you may need: * `vewsItems` and `viewItemsMap` - stores information about parent views for this page. It is used for example in breadcrumbs. The difference between them is only in the way information is stored: `viewItems` is an Array of Objects and `viewItemsMap` is a Map. * `page` - saves all information about current page. * `title` - title of current page.