mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
15
src/displays/define.ts
Normal file
15
src/displays/define.ts
Normal 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;
|
||||
}
|
||||
27
src/displays/format-title/format-title.story.ts
Normal file
27
src/displays/format-title/format-title.story.ts
Normal 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>
|
||||
`,
|
||||
});
|
||||
16
src/displays/format-title/format-title.test.ts
Normal file
16
src/displays/format-title/format-title.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
5
src/displays/format-title/handler.ts
Normal file
5
src/displays/format-title/handler.ts
Normal 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;
|
||||
9
src/displays/format-title/index.ts
Normal file
9
src/displays/format-title/index.ts
Normal 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,
|
||||
});
|
||||
5
src/displays/format-title/readme.md
Normal file
5
src/displays/format-title/readme.md
Normal 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.
|
||||
|
||||
24
src/displays/icon/icon.story.ts
Normal file
24
src/displays/icon/icon.story.ts
Normal 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" />
|
||||
`,
|
||||
});
|
||||
18
src/displays/icon/icon.test.ts
Normal file
18
src/displays/icon/icon.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
16
src/displays/icon/icon.vue
Normal file
16
src/displays/icon/icon.vue
Normal 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>
|
||||
9
src/displays/icon/index.ts
Normal file
9
src/displays/icon/index.ts
Normal 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,
|
||||
}));
|
||||
26
src/displays/icon/readme.md
Normal file
26
src/displays/icon/readme.md
Normal 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
4
src/displays/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import DisplayIcon from './icon/';
|
||||
|
||||
export const displays = [DisplayIcon];
|
||||
export default displays;
|
||||
11
src/displays/readme.md
Normal file
11
src/displays/readme.md
Normal 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
9
src/displays/register.ts
Normal 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
16
src/displays/types.ts
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import './views/register';
|
||||
import './modules/register';
|
||||
import './layouts/register';
|
||||
import './interfaces/register';
|
||||
import './displays/register';
|
||||
|
||||
import App from './app.vue';
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user