Add support for Displays (#249)

* Create displays extension type

* Optimize define

* Add missing layout component export

* Add readme

* Remove codesmell
This commit is contained in:
Rijk van Zanten
2020-03-25 16:55:01 -04:00
committed by GitHub
parent 8fb195a343
commit f7d4d3e1ac
29 changed files with 301 additions and 132 deletions

15
src/displays/define.ts Normal file
View File

@@ -0,0 +1,15 @@
import { i18n } from '@/lang';
import { DisplayDefineParam, DisplayContext, DisplayConfig } from './types';
export function defineDisplay(config: DisplayDefineParam): DisplayConfig {
let options: DisplayConfig;
if (typeof config === 'function') {
const context: DisplayContext = { i18n };
options = config(context);
} else {
options = config;
}
return options;
}

View File

@@ -0,0 +1,27 @@
import { withKnobs, text } from '@storybook/addon-knobs';
import readme from './readme.md';
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, computed } from '@vue/composition-api';
import handler from './handler';
export default {
title: 'Displays / Format Title',
parameters: { notes: readme },
decorators: [withPadding, withKnobs],
};
export const basic = () =>
defineComponent({
props: {
val: {
default: text('Value', 'hello-world'),
},
},
setup(props) {
const value = computed<string | null>(() => handler(props.val));
return { value };
},
template: `
<div>{{ value }}</div>
`,
});

View File

@@ -0,0 +1,16 @@
import handler from './handler';
import formatTitle from '@directus/format-title';
jest.mock('@directus/format-title');
describe('Displays / Format Title', () => {
it('Runs the value through the title formatter', () => {
handler('test');
expect(formatTitle).toHaveBeenCalledWith('test');
});
it('Does not pass the value if the value is falsy', () => {
handler(null);
expect(formatTitle).not.toHaveBeenCalledWith(null);
});
});

View File

@@ -0,0 +1,5 @@
import formatTitle from '@directus/format-title';
import { DisplayHandlerFunction } from '@/displays/types';
const handler: DisplayHandlerFunction = (value) => (value ? formatTitle(value) : null);
export default handler;

View File

@@ -0,0 +1,9 @@
import { defineDisplay } from '@/displays/define';
import handler from './handler';
export default defineDisplay({
id: 'format-title',
name: 'Format Title',
icon: 'text_format',
handler: handler,
});

View File

@@ -0,0 +1,5 @@
# Format Title (Function)
Runs the raw database value through the [Title Formatter](https://github.com/directus/format-title/)
and displays the result.

View File

@@ -0,0 +1,24 @@
import { defineComponent } from '@vue/composition-api';
import { withKnobs, text } from '@storybook/addon-knobs';
import readme from './readme.md';
import withPadding from '../../../.storybook/decorators/with-padding';
export default {
title: 'Displays / Icon',
parameters: {
notes: readme,
},
decorators: [withKnobs, withPadding],
};
export const basic = () =>
defineComponent({
props: {
iconName: {
default: text('Icon Name', 'subject'),
},
},
template: `
<display-icon :icon-name="iconName" />
`,
});

View File

@@ -0,0 +1,18 @@
import DisplayIcon from './icon.vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VIcon from '@/components/v-icon';
import VueCompositionAPI from '@vue/composition-api';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-icon', VIcon);
describe('Displays / Icon', () => {
it('Renders the icon component', () => {
const component = shallowMount(DisplayIcon, {
localVue,
});
expect(component.find(VIcon).exists()).toBe(true);
});
});

View File

@@ -0,0 +1,16 @@
<template functional>
<v-icon :name="props.iconName" />
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
props: {
iconName: {
type: String,
default: 'subject',
},
},
});
</script>

View File

@@ -0,0 +1,9 @@
import { defineDisplay } from '@/displays/define';
import DisplayIcon from './icon.vue';
export default defineDisplay(({ i18n }) => ({
id: 'icon',
name: i18n.t('displays.icon.icon'),
icon: 'box',
handler: DisplayIcon,
}));

View File

@@ -0,0 +1,26 @@
# Icon Display (Component)
Renders an icon, that's it. It doesn't care about the value that's saved.
This can be useful for long pieces of content, or binary, where it isn't relevant to see any part of
the value.
## Usage
```html
<display-icon icon-name="subject" />
```
## Props
| Prop | Description | Default |
|-------------|----------------------------|-----------|
| `icon-name` | Name of the icon to render | `subject` |
## Events
n/a
## Slots
n/a
## CSS Variables
n/a

4
src/displays/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import DisplayIcon from './icon/';
export const displays = [DisplayIcon];
export default displays;

11
src/displays/readme.md Normal file
View File

@@ -0,0 +1,11 @@
# Displays
Displays are functions / components that are used in the system to display data. They are small
wrappers that help display values in a matter that makes sense for the saved value, for example
rendering a color swatch for a saved color value.
## Functions vs Components
A _Display_ can either be a function, or a component. The function gets the value, and returns a
string of how to display this value. A Vue component similarly gets the value through the `value`
prop, and can render whatever makes sense for the value.

9
src/displays/register.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Component } from 'vue';
import registerComponent from '@/utils/register-component/';
import displays from './index';
displays.forEach((display) => {
if (typeof display.handler !== 'function') {
registerComponent('display-' + display.id, display.handler as Component);
}
});

16
src/displays/types.ts Normal file
View File

@@ -0,0 +1,16 @@
import VueI18n from 'vue-i18n';
import { Component } from 'vue';
export type DisplayHandlerFunction = (value: any) => string | null;
export type DisplayConfig = {
id: string;
icon: string;
name: string | VueI18n.TranslateResult;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: DisplayHandlerFunction | Component;
};
export type DisplayContext = { i18n: VueI18n };
export type DisplayDefineParam = DisplayConfig | ((context: DisplayContext) => DisplayConfig);

View File

@@ -1,13 +1,15 @@
import { i18n } from '@/lang';
import { Interface, InterfaceOptions, InterfaceContext } from './types';
import { InterfaceDefineParam, InterfaceContext, InterfaceConfig } from './types';
export function defineInterface(options: InterfaceOptions): Interface {
const context: InterfaceContext = { i18n };
export function defineInterface(config: InterfaceDefineParam): InterfaceConfig {
let options: InterfaceConfig;
const config = {
id: options.id,
...options.register(context),
};
if (typeof config === 'function') {
const context: InterfaceContext = { i18n };
options = config(context);
} else {
options = config;
}
return config;
return options;
}

View File

@@ -4,7 +4,7 @@ can be seen as the individual fields in a form, where the field is a single colu
## Defining interfaces
Interfaces need to be defined through the `defineInterface` function. This allows the interface to
register things like it's name, options, and the way it displays data across the platform.
register things like it's name and options.
```js
export default defineInterface({
@@ -12,10 +12,7 @@ export default defineInterface({
register: ({ i18n }) => ({
name: i18n.t('interfaces.text-input.text-input'),
icon: 'box',
component: InterfaceTextInput,
display: value => {
return formatTitle(value);
}
component: InterfaceTextInput
})
});
```
@@ -44,19 +41,3 @@ wizard.
#### `component`
The Vue component that makes up the input of the interface. This is the component that will be rendered
in the edit form.
#### `display`
Next to the actual input, interfaces have the ability to define the way it's value it's shown elsewhere
in the platform. This can be useful if the data needs to be manipulated before it can shown to the
end user in a way that makes sense. For example, one might want to convert a hex value into a color
swatch when the value is being shown in the platform.
This value can either be:
* `null`
render the raw value as stored in the database
* `(val) => string | number`
a callback function that converts the value before being shown
* Vue Component
a custom Vue component that will be rendered inline. Ideally this is a functional component, to
ensure performance when looking at large datasets.

View File

@@ -1,11 +1,7 @@
import registerComponent from '@/utils/register-component/';
import interfaces from './index';
// inter, cause interface is reserved keyword in JS... o_o
// inter, cause interface is a reserved keyword in JS... :C
interfaces.forEach((inter) => {
registerComponent('interface-' + inter.id, inter.component);
if (inter.display && typeof inter.display === 'object') {
registerComponent('display-' + inter.id, inter.display);
}
});

View File

@@ -1,15 +1,9 @@
import InterfaceTextInput from './text-input.vue';
import { defineInterface } from '@/interfaces/define';
import formatTitle from '@directus/format-title';
export default defineInterface({
export default defineInterface(({ i18n }) => ({
id: 'text-input',
register: ({ i18n }) => ({
name: i18n.t('interfaces.text-input.text-input'),
icon: 'box',
component: InterfaceTextInput,
display: (value) => {
return formatTitle(value);
},
}),
});
name: i18n.t('interfaces.text-input.text-input'),
icon: 'box',
component: InterfaceTextInput,
}));

View File

@@ -1,21 +1,15 @@
import VueI18n from 'vue-i18n';
import { Component } from 'vue';
export type InterfaceOptions = {
export type InterfaceConfig = {
id: string;
register: (context: InterfaceContext) => InterfaceConfig;
};
export interface InterfaceConfig {
icon: string;
name: string | VueI18n.TranslateResult;
component: Component;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
display: null | ((value: any) => string) | Component;
}
export interface Interface extends InterfaceConfig {
id: string;
}
};
export type InterfaceContext = { i18n: VueI18n };
export type InterfaceDefineParam =
| InterfaceConfig
| ((context: InterfaceContext) => InterfaceConfig);

View File

@@ -1,13 +1,15 @@
import { i18n } from '@/lang/';
import { Layout, LayoutOptions, LayoutContext } from './types';
import { i18n } from '@/lang';
import { LayoutDefineParam, LayoutContext, LayoutConfig } from './types';
export function defineLayout(options: LayoutOptions): Layout {
const context: LayoutContext = { i18n };
export function defineLayout(config: LayoutDefineParam): LayoutConfig {
let options: LayoutConfig;
const config = {
id: options.id,
...options.register(context),
};
if (typeof config === 'function') {
const context: LayoutContext = { i18n };
options = config(context);
} else {
options = config;
}
return config;
return options;
}

View File

@@ -1,11 +1,9 @@
import { defineLayout } from '@/layouts/define';
import TabularLayout from './tabular.vue';
export default defineLayout({
export default defineLayout(({ i18n }) => ({
id: 'tabular',
register: ({ i18n }) => ({
name: i18n.t('layouts.tabular.tabular'),
icon: 'table',
component: TabularLayout,
}),
});
name: i18n.t('layouts.tabular.tabular'),
icon: 'table',
component: TabularLayout,
}));

View File

@@ -1,24 +1,17 @@
import { Component } from 'vue';
import VueI18n from 'vue-i18n';
import { VueConstructor } from 'vue/types/umd';
import { VueConstructor, Component } from 'vue';
export type LayoutOptions = {
export type LayoutConfig = {
id: string;
register: (context: LayoutContext) => LayoutConfig;
};
export interface LayoutConfig {
icon: string;
name: string | VueI18n.TranslateResult;
component: Component;
}
export interface Layout extends LayoutConfig {
id: string;
}
};
export type LayoutContext = { i18n: VueI18n };
export type LayoutDefineParam = LayoutConfig | ((context: LayoutContext) => LayoutConfig);
export interface LayoutComponent extends VueConstructor {
refresh: () => Promise<void>;
}

View File

@@ -12,6 +12,7 @@ import './views/register';
import './modules/register';
import './layouts/register';
import './interfaces/register';
import './displays/register';
import App from './app.vue';

View File

@@ -3,29 +3,27 @@ import CollectionsOverview from './routes/overview/';
import CollectionsBrowse from './routes/browse/';
import CollectionsDetail from './routes/detail/';
export default defineModule({
export default defineModule(({ i18n }) => ({
id: 'collections',
register: ({ i18n }) => ({
name: i18n.tc('collection', 2),
routes: [
{
name: 'collections-overview',
path: '/',
component: CollectionsOverview,
},
{
name: 'collections-browse',
path: '/:collection',
component: CollectionsBrowse,
props: true,
},
{
name: 'collections-detail',
path: '/:collection/:primaryKey',
component: CollectionsDetail,
props: true,
},
],
icon: 'box',
}),
});
name: i18n.tc('collection', 2),
icon: 'box',
routes: [
{
name: 'collections-overview',
path: '/',
component: CollectionsOverview,
},
{
name: 'collections-browse',
path: '/:collection',
component: CollectionsBrowse,
props: true,
},
{
name: 'collections-detail',
path: '/:collection/:primaryKey',
component: CollectionsDetail,
props: true,
},
],
}));

View File

@@ -77,7 +77,7 @@ import { i18n } from '@/lang';
import api from '@/api';
import { LayoutComponent } from '@/layouts/types';
import useCollectionPresetsStore from '@/stores/collection-presets';
import { debounce, clone } from 'lodash';
import { debounce } from 'lodash';
const redirectIfNeeded: NavigationGuard = async (to, from, next) => {
const collectionsStore = useCollectionsStore();

View File

@@ -1,18 +1,20 @@
import { i18n } from '@/lang/';
import { Module, ModuleOptions, ModuleContext } from './types';
import { ModuleDefineParam, ModuleContext, ModuleConfig } from './types';
export function defineModule(options: ModuleOptions): Module {
const context: ModuleContext = { i18n };
export function defineModule(config: ModuleDefineParam): ModuleConfig {
let options: ModuleConfig;
const config = {
id: options.id,
...options.register(context),
};
if (typeof config === 'function') {
const context: ModuleContext = { i18n };
options = config(context);
} else {
options = config;
}
config.routes = config.routes.map((route) => ({
options.routes = options.routes.map((route) => ({
...route,
path: `/:project/${config.id}${route.path}`,
path: `/:project/${options.id}${route.path}`,
}));
return config;
return options;
}

View File

@@ -1,19 +1,13 @@
import VueI18n from 'vue-i18n';
import { RouteConfig } from 'vue-router';
export type ModuleOptions = {
export type ModuleConfig = {
id: string;
register: (context: ModuleContext) => ModuleConfig;
};
export interface ModuleConfig {
routes: RouteConfig[];
icon: string;
name: string | VueI18n.TranslateResult;
}
export interface Module extends ModuleConfig {
id: string;
}
routes: RouteConfig[];
};
export type ModuleContext = { i18n: VueI18n };
export type ModuleDefineParam = ModuleConfig | ((context: ModuleContext) => ModuleConfig);