Use regular dropdown for default status field, make form work without field meta

This commit is contained in:
rijkvanzanten
2020-09-02 15:50:43 -04:00
parent 7d9a0a01aa
commit c985fbbfb8
16 changed files with 42 additions and 550 deletions

View File

@@ -9,11 +9,13 @@
<component
v-if="interfaceExists"
:is="`interface-${field.meta.interface}`"
v-bind="field.meta.options"
:is="
field.meta ? `interface-${field.meta.interface}` : `interface-${getDefaultInterfaceForType(field.type)}`
"
v-bind="(field.meta && field.meta.options) || {}"
:disabled="disabled"
:value="value === undefined ? field.schema.default_value : value"
:width="field.meta.width"
:width="(field.meta && field.meta.width) || 'full'"
:type="field.type"
:collection="field.collection"
:field="field.field"
@@ -23,7 +25,7 @@
/>
<v-notice v-else type="warning">
{{ $t('interface_not_found', { interface: field.meta.interface }) }}
{{ $t('interface_not_found', { interface: field.meta && field.meta.interface }) }}
</v-notice>
</div>
</template>
@@ -32,6 +34,7 @@
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Field } from '@/types';
import { getInterfaces } from '@/interfaces';
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
export default defineComponent({
props: {
@@ -68,10 +71,10 @@ export default defineComponent({
const interfaces = getInterfaces();
const interfaceExists = computed(() => {
return !!interfaces.value.find((inter) => inter.id === props.field.meta.interface);
return !!interfaces.value.find((inter) => inter.id === props.field?.meta?.interface || 'text-input');
});
return { interfaceExists };
return { interfaceExists, getDefaultInterfaceForType };
},
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="field" :key="field.field" :class="field.meta.width">
<div class="field" :key="field.field" :class="(field.meta && field.meta.width) || 'full'">
<v-menu v-if="field.hideLabel !== true" placement="bottom-start" show-arrow :disabled="isDisabled">
<template #activator="{ toggle, active }">
<form-field-label
@@ -33,7 +33,7 @@
@input="$emit('input', $event)"
/>
<small class="note" v-if="field.meta.note" v-html="marked(field.meta.note)" />
<small class="note" v-if="field.meta && field.meta.note" v-html="marked(field.meta.note)" />
</div>
</template>
@@ -84,7 +84,7 @@ export default defineComponent({
setup(props) {
const isDisabled = computed(() => {
if (props.disabled) return true;
if (props.field.meta.readonly) return true;
if (props.field?.meta?.readonly === true) return true;
if (props.batchMode && props.batchActive === false) return true;
return false;
});

View File

@@ -5,12 +5,13 @@ import getDefaultInterfaceForType from '@/utils/get-default-interface-for-type';
import { getInterfaces } from '@/interfaces';
import { FormField } from '@/components/v-form/types';
import { Field } from '@/types';
import { clone } from 'lodash';
export default function useFormFields(fields: Ref<Field[]>) {
const interfaces = getInterfaces();
const formFields = computed(() => {
let formFields = [...fields.value];
let formFields = clone(fields.value);
// Sort the fields on the sort column value
formFields = formFields.sort((a, b) => {
@@ -24,31 +25,7 @@ export default function useFormFields(fields: Ref<Field[]>) {
});
formFields = formFields.map((field, index) => {
if (!field.meta) {
field.meta = {
id: -1,
collection: field.collection,
field: field.field,
group: null,
hidden: false,
locked: false,
interface: null,
options: null,
display: null,
display_options: null,
readonly: false,
required: false,
sort: null,
special: null,
translation: null,
width: 'full',
note: null,
};
}
if (!field.meta.width) {
field.meta.width = 'full';
}
if (!field.meta) return field;
let interfaceUsed = interfaces.value.find((int) => int.id === field.meta.interface);
const interfaceExists = interfaceUsed !== undefined;

View File

@@ -1,11 +0,0 @@
import { defineDisplay } from '@/displays/define';
import DisplayStatusBadge from './status-badge.vue';
export default defineDisplay(({ i18n }) => ({
id: 'status-badge',
name: i18n.t('status_badge'),
types: ['string'],
icon: 'flag',
handler: DisplayStatusBadge,
options: null,
}));

View File

@@ -1,4 +0,0 @@
# Status Badge
Renders the set status formatted according to the status mapping set in the interface options.

View File

@@ -1,53 +0,0 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { withKnobs, text, object } from '@storybook/addon-knobs';
import readme from './readme.md';
import { defineComponent } from '@vue/composition-api';
export default {
title: 'Displays / Status (Badge)',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
const defaultStatusMapping = {
published: {
name: 'Published',
value: 'published',
text_color: '#fff',
background_color: 'var(--primary)',
},
draft: {
name: 'Draft',
value: 'draft',
text_color: 'var(--primary-subdued)',
background_color: 'var(--background-subdued)',
},
deleted: {
name: 'Deleted',
value: 'deleted',
text_color: 'var(--danger)',
background_color: 'var(--danger-alt)',
},
};
export const basic = () =>
defineComponent({
props: {
value: {
default: text('Value', 'published'),
},
statusMapping: {
default: object('Status Mapping', defaultStatusMapping),
},
},
template: `
<display-status-badge
:value="value"
:interface-options="{
status_mapping: statusMapping,
}"
/>
`,
});

View File

@@ -1,73 +0,0 @@
import DisplayStatusBadge from './status-badge.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VIcon from '@/components/v-icon';
import VueCompositionAPI from '@vue/composition-api';
import Tooltip from '@/directives/tooltip';
const localVue = createLocalVue();
localVue.component('v-icon', VIcon);
localVue.use(VueCompositionAPI);
localVue.directive('tooltip', Tooltip);
describe('Displays / Status Badge', () => {
it('Renders an empty span if no value is passed', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: null,
},
});
expect(component.find('span').exists()).toBe(true);
expect(component.find('span').text()).toBe('');
});
it('Renders a question mark icon is status is unknown in interface options', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: 'draft',
interfaceOptions: {
status_mapping: {
published: {},
},
},
},
});
expect(component.find(VIcon).exists()).toBe(true);
expect(component.attributes('name')).toBe('help_outline');
});
it('Renders the badge with the correct colors', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: 'draft',
interfaceOptions: {
status_mapping: {
draft: {
background_color: 'rgb(171, 202, 188)',
text_color: 'rgb(150, 100, 125)',
},
},
},
},
});
expect(component.exists()).toBe(true);
expect(component.attributes('style')).toBe('background-color: rgb(171, 202, 188); color: rgb(150, 100, 125);');
});
it('Sets status to null if interface options are missing', () => {
const component = shallowMount(DisplayStatusBadge, {
localVue,
propsData: {
value: 'draft',
interfaceOptions: null,
},
});
expect((component.vm as any).status).toBe(null);
});
});

View File

@@ -1,51 +0,0 @@
<template>
<span v-if="!value" />
<v-icon name="help_outline" v-else-if="!status" />
<div
v-else
class="badge type-text"
:style="{
backgroundColor: status.background_color,
color: status.text_color,
}"
>
{{ status.name }}
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
interfaceOptions: {
type: Object,
default: null,
},
},
setup(props) {
const status = computed(() => {
if (props.interfaceOptions === null) return null;
return props.interfaceOptions.status_mapping?.[props.value];
});
return { status };
},
});
</script>
<style lang="scss" scoped>
.badge {
display: inline-block;
padding: 8px;
color: var(--foreground-inverted);
line-height: 1;
vertical-align: middle;
border-radius: var(--border-radius);
}
</style>

View File

@@ -1,25 +0,0 @@
import InterfaceStatus from './status.vue';
import { defineInterface } from '@/interfaces/define';
export default defineInterface(({ i18n }) => ({
id: 'status',
name: i18n.t('status'),
icon: 'bubble_chart',
component: InterfaceStatus,
types: ['string'],
options: [
/** @TODO change this to a custom options element */
{
field: 'status_mapping',
name: i18n.t('status_mapping'),
type: 'json',
meta: {
width: 'full',
interface: 'code',
options: {
language: 'json'
}
}
},
],
}));

View File

@@ -1,26 +0,0 @@
# Status Interface
Renders a dropdown with the available status options.
## Options
| Option | Description | Default |
|------------------|-----------------------------|---------|
| `status_mapping` | What statuses are available | `null` |
### Status Mapping format
```ts
type Status = {
[key: string]: {
name: string;
text_color: string;
background_color: string;
soft_delete: boolean;
published: boolean;
}
}
```
`status_mapping` is the only option for an interface that isn't camelCased. This is due to the fact
that the API relies on the same setting for it's permissions management.

View File

@@ -1,55 +0,0 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref } from '@vue/composition-api';
import { boolean, withKnobs, object } from '@storybook/addon-knobs';
import readme from './readme.md';
import RawValue from '../../../.storybook/raw-value.vue';
import i18n from '@/lang';
export default {
title: 'Interfaces / Status',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
export const basic = () =>
defineComponent({
i18n,
components: { RawValue },
props: {
statusMapping: {
default: object('Status Mapping', {
published: {
name: 'Published',
background_color: 'var(--primary)',
},
draft: {
name: 'Draft',
background_color: 'var(--background-normal)',
},
deleted: {
name: 'Deleted',
background_color: 'var(--danger)',
},
}),
},
disabled: {
default: boolean('Disabled', false),
},
},
setup() {
const value = ref(null);
return { value };
},
template: `
<div style="max-width: 300px;">
<interface-status
v-model="value"
:status_mapping="statusMapping"
:disabled="disabled"
/>
<raw-value>{{ value }}</raw-value>
</div>
`,
});

View File

@@ -1,66 +0,0 @@
import VueCompositionAPI from '@vue/composition-api';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import InterfaceStatus from './status.vue';
import VNotice from '@/components/v-notice';
import VMenu from '@/components/v-menu';
import VList, { VListItem, VListItemIcon, VListItemContent } from '@/components/v-list';
import i18n from '@/lang';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-notice', VNotice);
localVue.component('v-menu', VMenu);
localVue.component('v-list', VList);
localVue.component('v-list-item', VListItem);
localVue.component('v-list-item-content', VListItemContent);
localVue.component('v-list-item-icon', VListItemIcon);
describe('Interfaces / Slider', () => {
it('Renders a notice when status mapping is missing', () => {
const component = shallowMount(InterfaceStatus, {
localVue,
i18n,
propsData: {},
});
expect(component.find(VNotice).exists()).toBe(true);
});
it('Converts the status mapping into a loopable array', () => {
const component = shallowMount(InterfaceStatus, {
localVue,
i18n,
propsData: {
status_mapping: null,
},
});
expect((component.vm as any).statuses).toBe(null);
component.setProps({
status_mapping: {
test: {
name: 'Test',
background_color: '#abcabc',
},
another_test: {
name: 'Another Test',
background_color: '#123123',
},
},
});
expect((component.vm as any).statuses).toEqual([
{
value: 'test',
name: 'Test',
color: '#abcabc',
},
{
value: 'another_test',
name: 'Another Test',
color: '#123123',
},
]);
});
});

View File

@@ -1,99 +0,0 @@
<template>
<v-notice v-if="!statuses">
{{ $t('statuses_not_configured') }}
</v-notice>
<v-menu v-else attached :disabled="disabled">
<template #activator="{ toggle, active }">
<v-input
readonly
@click="toggle"
:value="current ? current.name : null"
:placeholder="$t('choose_status')"
:disabled="disabled"
>
<template #prepend>
<div
class="status-dot"
:style="
current
? { backgroundColor: current.backgroundColor }
: { backgroundColor: 'var(--border-normal)' }
"
/>
</template>
<template #append><v-icon name="expand_more" :class="{ active }" /></template>
</v-input>
</template>
<v-list dense>
<v-list-item
v-for="(status, key) in statuses"
:key="key"
:active="key === value"
@click="$emit('input', key)"
>
<v-list-item-icon>
<div class="status-dot" :style="{ backgroundColor: status.backgroundColor }" />
</v-list-item-icon>
<v-list-item-content>{{ status.name }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
statuses: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props) {
const current = computed(() => {
if (props.value === null) return null;
if (props.statuses.hasOwnProperty(props.value) === false) return null;
return props.statuses[props.value] || null;
});
return { current };
},
});
</script>
<style lang="scss" scoped>
.v-input {
.v-icon {
transition: transform var(--medium) var(--transition-out);
&.active {
transform: scaleY(-1);
transition-timing-function: var(--transition-in);
}
}
}
.status-dot {
width: 14px;
height: 14px;
border: 2px solid var(--background-page);
border-radius: 7px;
}
.v-list {
.status-dot {
border: 2px solid var(--background-subdued);
}
}
</style>

View File

@@ -7,7 +7,7 @@
</v-card-title>
<v-card-text>
{{ saveError && saveError.response && saveErrors.response.data.error.message }}
{{ saveError && saveError.response && saveError.response.data.errors[0].message }}
</v-card-text>
<v-card-actions>
@@ -120,6 +120,10 @@ export default defineComponent({
const sortField = ref<string>();
const archiveField = ref<string>();
const archiveValue = ref<string>();
const unarchiveValue = ref<string>();
const systemFields = reactive({
status: {
enabled: false,
@@ -186,6 +190,9 @@ export default defineComponent({
collection: collectionName.value,
fields: [getPrimaryKeyField(), ...getSystemFields()],
sort_field: sortField.value,
archive_field: archiveField.value,
archive_value: archiveValue.value,
unarchive_value: unarchiveValue.value,
});
await collectionsStore.hydrate();
@@ -269,31 +276,31 @@ export default defineComponent({
width: 'full',
required: true,
options: {
statuses: {
published: {
name: 'Published',
color: 'white',
backgroundColor: '#2f80ed',
choices: [
{
value: 'published',
text: 'Published',
},
draft: {
name: 'Draft',
color: 'white',
backgroundColor: '#eceff1',
{
text: 'Draft',
value: 'draft',
},
deleted: {
name: 'Deleted',
color: 'white',
backgroundColor: '#eb5757',
softDelete: true,
{
text: 'Archived',
value: 'archived',
},
},
],
},
interface: 'status',
interface: 'dropdown',
},
schema: {
default_value: 'draft',
},
});
archiveField.value = 'status';
archiveValue.value = 'archived';
unarchiveValue.value = 'draft';
}
// Sort

View File

@@ -36,33 +36,6 @@ const fakeFilesField: Field = {
},
};
function getMetaDefault(collection: string, field: string): Field['meta'] {
/**
* @TODO
*
* Get rid of this. Have it work without any meta
*/
return {
id: -1,
collection,
field,
group: null,
hidden: false,
interface: null,
display: null,
display_options: null,
locked: false,
options: null,
readonly: false,
required: false,
sort: null,
special: null,
translation: null,
width: 'full',
note: null,
};
}
export const useFieldsStore = createStore({
id: 'fieldsStore',
state: () => ({
@@ -92,13 +65,11 @@ export const useFieldsStore = createStore({
parseField(field: FieldRaw): Field {
let name: string | VueI18n.TranslateResult;
const meta = field.meta === null ? getMetaDefault(field.collection, field.field) : field.meta;
if (i18n.te(`fields.${field.collection}.${field.field}`)) {
name = i18n.t(`fields.${field.collection}.${field.field}`);
} else if (notEmpty(meta.translation) && meta.translation.length > 0) {
for (let i = 0; i < meta.translation.length; i++) {
const { locale, translation } = meta.translation[i];
} else if (field.meta && notEmpty(field.meta.translation) && field.meta.translation.length > 0) {
for (let i = 0; i < field.meta.translation.length; i++) {
const { locale, translation } = field.meta.translation[i];
i18n.mergeLocaleMessage(locale, {
fields: {
@@ -117,7 +88,6 @@ export const useFieldsStore = createStore({
return {
...field,
name,
meta,
};
},
async createField(collectionKey: string, newField: Field) {

View File

@@ -76,6 +76,4 @@ export interface FieldRaw {
export interface Field extends FieldRaw {
name: string | TranslateResult;
type: typeof types[number];
meta: FieldMeta;
}