mirror of
https://github.com/directus/directus.git
synced 2026-02-07 22:25:11 -05:00
Add dropdown interface (#461)
* Add show-deselect option to v-select * Add parse-choices util * Add dropdown interface * Add allow-other prop to v-select (single only) * Check for custom state correctly * Treat empty custom value as null * Set full-width to true by default for inputs / selects * Add allow-other support to multiple dropdown * Upgrade display value to show item count * Fix custom deletion * Fix tests * Pass allow other on in dropdown interface
This commit is contained in:
@@ -58,7 +58,7 @@ export const monospace = () => ({
|
||||
export const disabled = () => `<v-input value="I'm disabled" disabled />`;
|
||||
|
||||
export const fullWidth = () => `
|
||||
<v-input placeholder="Enter content..." full-width />
|
||||
<v-input placeholder="Enter content..." />
|
||||
`;
|
||||
|
||||
export const forceSlug = () =>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default defineComponent({
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
|
||||
@@ -132,13 +132,17 @@ export default defineComponent({
|
||||
background-color: var(--v-list-item-background-color-hover);
|
||||
}
|
||||
|
||||
&:not(.disabled):active,
|
||||
&.active {
|
||||
&:not(.disabled):active {
|
||||
color: var(--v-list-item-color-active);
|
||||
background-color: var(--v-list-item-background-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--v-list-item-color-active);
|
||||
background-color: var(--v-list-item-background-color-active);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--v-list-item-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
@@ -22,16 +22,17 @@ Renders a dropdown input.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Description | Default |
|
||||
| ------------- | --------------------------------------------------- | ------- |
|
||||
| `items`\* | Items to render in the select | |
|
||||
| `itemText` | What item value to use for the display text | `text` |
|
||||
| `itemValue` | What item value to use for the item value | `value` |
|
||||
| `value` | Currently selected item(s) | |
|
||||
| `multiple` | Allow multiple items to be selected | `false` |
|
||||
| `placeholder` | What placeholder to show when no items are selected | |
|
||||
| `full-width` | Render the select at full width | |
|
||||
| `disabled` | Disable the select | |
|
||||
| Prop | Description | Default |
|
||||
|-----------------|-----------------------------------------------------|---------|
|
||||
| `items`\* | Items to render in the select | |
|
||||
| `itemText` | What item value to use for the display text | `text` |
|
||||
| `itemValue` | What item value to use for the item value | `value` |
|
||||
| `value` | Currently selected item(s) | |
|
||||
| `multiple` | Allow multiple items to be selected | `false` |
|
||||
| `placeholder` | What placeholder to show when no items are selected | |
|
||||
| `full-width` | Render the select at full width | |
|
||||
| `disabled` | Disable the select | |
|
||||
| `show-deselect` | Show the deselect option when a value has been set | |
|
||||
|
||||
|
||||
## Events
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import readme from './readme.md';
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { withKnobs, array, text } from '@storybook/addon-knobs';
|
||||
import { withKnobs, array, text, boolean } from '@storybook/addon-knobs';
|
||||
import RawValue from '../../../.storybook/raw-value.vue';
|
||||
import i18n from '@/lang';
|
||||
|
||||
import VSelect from './v-select.vue';
|
||||
|
||||
@@ -16,6 +17,7 @@ export default {
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
i18n,
|
||||
components: { VSelect, RawValue },
|
||||
props: {
|
||||
items: {
|
||||
@@ -24,14 +26,26 @@ export const basic = () =>
|
||||
placeholder: {
|
||||
default: text('Placeholder', 'Enter value...'),
|
||||
},
|
||||
showDeselect: {
|
||||
default: boolean('Show Deselect', false),
|
||||
},
|
||||
allowOther: {
|
||||
default: boolean('Allow Other', false),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref(null);
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<v-select :placeholder="placeholder" v-model="value" :items="items" />
|
||||
<div style="max-width: 300px;">
|
||||
<v-select
|
||||
:show-deselect="showDeselect"
|
||||
:allow-other="allowOther"
|
||||
:placeholder="placeholder"
|
||||
v-model="value"
|
||||
:items="items"
|
||||
/>
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
@@ -39,6 +53,7 @@ export const basic = () =>
|
||||
|
||||
export const multiple = () =>
|
||||
defineComponent({
|
||||
i18n,
|
||||
components: { VSelect, RawValue },
|
||||
props: {
|
||||
items: {
|
||||
@@ -47,14 +62,27 @@ export const multiple = () =>
|
||||
placeholder: {
|
||||
default: text('Placeholder', 'Enter value...'),
|
||||
},
|
||||
showDeselect: {
|
||||
default: boolean('Show Deselect', false),
|
||||
},
|
||||
allowOther: {
|
||||
default: boolean('Allow Other', false),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref(null);
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<v-select :placeholder="placeholder" v-model="value" :items="items" multiple />
|
||||
<div style="max-width: 300px;">
|
||||
<v-select
|
||||
:show-deselect="showDeselect"
|
||||
:allow-other="allowOther"
|
||||
:placeholder="placeholder"
|
||||
v-model="value"
|
||||
:items="items"
|
||||
multiple
|
||||
/>
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<v-menu
|
||||
:disabled="disabled"
|
||||
class="v-select"
|
||||
attached
|
||||
:close-on-content-click="multiple === false"
|
||||
>
|
||||
<v-menu :disabled="disabled" class="v-select" attached>
|
||||
<template #activator="{ toggle }">
|
||||
<v-input
|
||||
:full-width="fullWidth"
|
||||
@@ -14,17 +9,31 @@
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #prepend><slot name="prepend" /></template>
|
||||
<template #append><v-icon name="expand_more" /></template>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<template v-if="showDeselect">
|
||||
<v-list-item @click="$emit('input', null)" :disabled="value === null">
|
||||
<v-list-item-icon v-if="multiple === true">
|
||||
<v-icon name="close" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ multiple ? $t('deselect_all') : $t('deselect') }}
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon v-if="multiple === false">
|
||||
<v-icon name="close" />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="item in _items"
|
||||
:key="item.value"
|
||||
:class="{
|
||||
active: multiple ? (value || []).includes(item.value) : value === item.value,
|
||||
}"
|
||||
:active="multiple ? (value || []).includes(item.value) : value === item.value"
|
||||
@click="multiple ? null : $emit('input', item.value)"
|
||||
>
|
||||
<v-list-item-content>
|
||||
@@ -38,22 +47,70 @@
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-if="allowOther && multiple === false" :active="usesOtherValue">
|
||||
<v-list-item-content>
|
||||
<input
|
||||
class="other-input"
|
||||
@focus="otherValue ? $emit('input', otherValue) : null"
|
||||
v-model="otherValue"
|
||||
:placeholder="$t('other')"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<template v-if="allowOther && multiple === true">
|
||||
<v-list-item
|
||||
v-for="otherValue in otherValues"
|
||||
:key="otherValue.key"
|
||||
:active="(value || []).includes(otherValue.value)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-checkbox
|
||||
:inputValue="value || []"
|
||||
:value="otherValue.value"
|
||||
@change="$emit('input', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<input
|
||||
class="other-input"
|
||||
:value="otherValue.value"
|
||||
:placeholder="$t('other')"
|
||||
v-focus
|
||||
@input="setOtherValue(otherValue.key, $event.target.value)"
|
||||
@blur="
|
||||
otherValue.value.length === 0 && setOtherValue(otherValue.key, null)
|
||||
"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="close" @click="setOtherValue(otherValue.key, null)" />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click="addOtherValue()">
|
||||
<v-list-item-icon><v-icon name="add" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ $t('other') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { defineComponent, PropType, computed, ref, watch } from '@vue/composition-api';
|
||||
import { nanoid } from 'nanoid';
|
||||
import i18n from '@/lang';
|
||||
|
||||
type Item = {
|
||||
text: string;
|
||||
value: string | number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ItemsRaw = (string | any)[];
|
||||
type InputValue = (string | number)[] | string | number;
|
||||
type InputValue = string[] | string;
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -83,82 +140,221 @@ export default defineComponent({
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowNull: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showDeselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowOther: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const _items = computed(() => {
|
||||
const items = props.items.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
value: item,
|
||||
};
|
||||
}
|
||||
setup(props, { emit }) {
|
||||
const { _items } = useItems();
|
||||
const { displayValue } = useDisplayValue();
|
||||
const { otherValue, usesOtherValue } = useCustomValue();
|
||||
const { otherValues, addOtherValue, setOtherValue } = useMultipleCustomValues();
|
||||
|
||||
return {
|
||||
text: item[props.itemText],
|
||||
value: item[props.itemValue],
|
||||
};
|
||||
return {
|
||||
_items,
|
||||
displayValue,
|
||||
otherValue,
|
||||
usesOtherValue,
|
||||
otherValues,
|
||||
addOtherValue,
|
||||
setOtherValue,
|
||||
};
|
||||
|
||||
function useItems() {
|
||||
const _items = computed(() => {
|
||||
const items = props.items.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
value: item,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: item[props.itemText],
|
||||
value: item[props.itemValue],
|
||||
};
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
if (props.allowNull) {
|
||||
items.unshift({
|
||||
text: i18n.t('none'),
|
||||
value: null,
|
||||
});
|
||||
return { _items };
|
||||
}
|
||||
|
||||
function useDisplayValue() {
|
||||
const displayValue = computed(() => {
|
||||
if (Array.isArray(props.value)) {
|
||||
if (props.value.length < 3) {
|
||||
return props.value
|
||||
.map((value) => {
|
||||
return getTextForValue(value) || value;
|
||||
})
|
||||
.join(', ');
|
||||
} else {
|
||||
const itemCount = _items.value.length + otherValues.value.length;
|
||||
const selectionCount = props.value.length;
|
||||
|
||||
if (itemCount === selectionCount) {
|
||||
return i18n.t('all_items');
|
||||
} else {
|
||||
return i18n.tc('item_count', selectionCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getTextForValue(props.value) || props.value;
|
||||
});
|
||||
|
||||
return { displayValue };
|
||||
|
||||
function getTextForValue(value: string | number) {
|
||||
return _items.value.find((item) => item.value === value)?.['text'];
|
||||
}
|
||||
}
|
||||
|
||||
function useCustomValue() {
|
||||
const localOtherValue = ref('');
|
||||
|
||||
const otherValue = computed({
|
||||
get() {
|
||||
return localOtherValue.value;
|
||||
},
|
||||
set(newValue: string | null) {
|
||||
if (newValue === null) {
|
||||
localOtherValue.value = '';
|
||||
emit('input', null);
|
||||
} else {
|
||||
localOtherValue.value = newValue;
|
||||
emit('input', newValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const usesOtherValue = computed(() => {
|
||||
// Check if set value is one of the existing keys
|
||||
const values = _items.value.map((item) => item.value);
|
||||
return (
|
||||
props.value !== null &&
|
||||
props.value.length > 0 &&
|
||||
values.includes(props.value) === false
|
||||
);
|
||||
});
|
||||
|
||||
return { otherValue, usesOtherValue };
|
||||
}
|
||||
|
||||
function useMultipleCustomValues() {
|
||||
type OtherValue = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const otherValues = ref<OtherValue[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
if (newValue === null) return;
|
||||
if (Array.isArray(newValue) === false) return;
|
||||
|
||||
(newValue as string[]).forEach((value) => {
|
||||
const values = _items.value.map((item) => item.value);
|
||||
const existsInValues = values.includes(value) === true;
|
||||
|
||||
if (existsInValues === false) {
|
||||
const other = otherValues.value.map((o) => o.value);
|
||||
const existsInOtherValues = other.includes(value) === true;
|
||||
|
||||
if (existsInOtherValues === false) {
|
||||
addOtherValue(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return { otherValues, addOtherValue, setOtherValue };
|
||||
|
||||
function addOtherValue(value = '') {
|
||||
otherValues.value = [
|
||||
...otherValues.value,
|
||||
{
|
||||
key: nanoid(),
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
function setOtherValue(key: string, newValue: string | null) {
|
||||
const previousValue = otherValues.value.find((o) => o.key === key);
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (Array.isArray(props.value)) {
|
||||
return props.value
|
||||
.map((value) => {
|
||||
return getTextForValue(value);
|
||||
})
|
||||
.join(', ');
|
||||
const valueWithoutPrevious = ((props.value || []) as string[]).filter(
|
||||
(val) => val !== previousValue?.value
|
||||
);
|
||||
|
||||
if (newValue === null) {
|
||||
otherValues.value = otherValues.value.filter((o) => o.key !== key);
|
||||
|
||||
if (valueWithoutPrevious.length === 0) {
|
||||
emit('input', null);
|
||||
} else {
|
||||
emit('input', valueWithoutPrevious);
|
||||
}
|
||||
} else {
|
||||
otherValues.value = otherValues.value.map((otherValue) => {
|
||||
if (otherValue.key === key) otherValue.value = newValue;
|
||||
return otherValue;
|
||||
});
|
||||
|
||||
const newEmitValue = [...valueWithoutPrevious, newValue];
|
||||
|
||||
emit('input', newEmitValue);
|
||||
}
|
||||
}
|
||||
|
||||
return getTextForValue(props.value);
|
||||
});
|
||||
|
||||
return { _items, displayValue };
|
||||
|
||||
function getTextForValue(value: string | number) {
|
||||
return _items.value.find((item) => item.value === value)?.['text'];
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-select {
|
||||
<style>
|
||||
:root {
|
||||
--v-select-font-family: var(--family-sans-serif);
|
||||
|
||||
font-family: var(--v-select-font-family);
|
||||
|
||||
.item-text {
|
||||
font-family: var(--v-select-font-family);
|
||||
}
|
||||
|
||||
.v-input {
|
||||
--v-input-font-family: var(--v-select-font-family);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
::v-deep input {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.item-text {
|
||||
font-family: var(--v-select-font-family);
|
||||
}
|
||||
|
||||
.v-input {
|
||||
--v-input-font-family: var(--v-select-font-family);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
::v-deep input {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.other-input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.2;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,11 @@ describe('Textarea', () => {
|
||||
|
||||
await component.vm.$nextTick();
|
||||
|
||||
expect(component.find('.v-textarea').classes()).toEqual(['v-textarea', 'disabled']);
|
||||
expect(component.find('.v-textarea').classes()).toEqual([
|
||||
'v-textarea',
|
||||
'disabled',
|
||||
'full-width',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Emits just the value for the input event', async () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default defineComponent({
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
|
||||
62
src/interfaces/dropdown/dropdown.story.ts
Normal file
62
src/interfaces/dropdown/dropdown.story.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import { boolean, withKnobs, text } from '@storybook/addon-knobs';
|
||||
import readme from './readme.md';
|
||||
import i18n from '@/lang';
|
||||
import RawValue from '../../../.storybook/raw-value.vue';
|
||||
|
||||
export default {
|
||||
title: 'Interfaces / Dropdown',
|
||||
decorators: [withPadding, withKnobs],
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
components: { RawValue },
|
||||
i18n,
|
||||
props: {
|
||||
allowOther: {
|
||||
default: boolean('Allow Other', false),
|
||||
},
|
||||
allowNone: {
|
||||
default: boolean('Allow None', false),
|
||||
},
|
||||
placeholder: {
|
||||
default: text('Placeholder', 'Select something'),
|
||||
},
|
||||
choices: {
|
||||
default: text(
|
||||
'Choices',
|
||||
`
|
||||
Option A
|
||||
Option B
|
||||
custom_value::Option C
|
||||
trim :: Option D
|
||||
`
|
||||
),
|
||||
},
|
||||
icon: {
|
||||
default: text('Icon', 'person'),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref(null);
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 300px;">
|
||||
<interface-dropdown
|
||||
v-model="value"
|
||||
:allow-other="allowOther"
|
||||
:allow-none="allowNone"
|
||||
:placeholder="placeholder"
|
||||
:choices="choices"
|
||||
:icon="icon"
|
||||
/>
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
45
src/interfaces/dropdown/dropdown.test.ts
Normal file
45
src/interfaces/dropdown/dropdown.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
|
||||
import VNotice from '@/components/v-notice';
|
||||
import VSelect from '@/components/v-select';
|
||||
import VIcon from '@/components/v-icon';
|
||||
import InterfaceDropdown from './dropdown.vue';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import i18n from '@/lang';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.use(VueI18n);
|
||||
localVue.component('v-select', VSelect);
|
||||
localVue.component('v-notice', VNotice);
|
||||
localVue.component('v-icon', VIcon);
|
||||
|
||||
describe('Interfaces / Dropdown', () => {
|
||||
it('Renders a notice when choices arent set', async () => {
|
||||
const component = shallowMount(InterfaceDropdown, {
|
||||
localVue,
|
||||
i18n,
|
||||
listeners: {
|
||||
input: () => undefined,
|
||||
},
|
||||
});
|
||||
expect(component.find(VNotice).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('Renders select when choices exist', async () => {
|
||||
const component = shallowMount(InterfaceDropdown, {
|
||||
localVue,
|
||||
i18n,
|
||||
listeners: {
|
||||
input: () => undefined,
|
||||
},
|
||||
propsData: {
|
||||
choices: `
|
||||
test
|
||||
`,
|
||||
},
|
||||
});
|
||||
expect(component.find(VSelect).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
66
src/interfaces/dropdown/dropdown.vue
Normal file
66
src/interfaces/dropdown/dropdown.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<v-notice v-if="!items" warning>
|
||||
{{ $t('choices_option_configured_incorrectly') }}
|
||||
</v-notice>
|
||||
<v-select
|
||||
v-else
|
||||
:value="value"
|
||||
@input="$listeners.input"
|
||||
:items="items"
|
||||
:disabled="disabled"
|
||||
:show-deselect="allowNone"
|
||||
:placeholder="placeholder"
|
||||
:allow-other="allowOther"
|
||||
>
|
||||
<template #prepend v-if="icon">
|
||||
<v-icon :name="icon" />
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import parseChoices from '@/utils/parse-choices';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
choices: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
allowNone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
allowOther: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const items = computed(() => {
|
||||
if (props.choices === null || props.choices.length === 0) return null;
|
||||
|
||||
return parseChoices(props.choices);
|
||||
});
|
||||
|
||||
return { items };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
38
src/interfaces/dropdown/index.ts
Normal file
38
src/interfaces/dropdown/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
import InterfaceDropdown from './dropdown.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'dropdown',
|
||||
name: i18n.t('dropdown'),
|
||||
icon: 'arrow_drop_down_circle',
|
||||
component: InterfaceDropdown,
|
||||
options: [
|
||||
{
|
||||
field: 'choices',
|
||||
name: i18n.t('choices'),
|
||||
note: i18n.t('use_double_colon_for_key'),
|
||||
width: 'full',
|
||||
interface: 'textarea',
|
||||
},
|
||||
{
|
||||
field: 'allowOther',
|
||||
name: i18n.t('allow_other'),
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
default_value: false,
|
||||
},
|
||||
{
|
||||
field: 'allowNone',
|
||||
name: i18n.t('allow_none'),
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
default_value: false,
|
||||
},
|
||||
{
|
||||
field: 'icon',
|
||||
name: i18n.t('icon'),
|
||||
width: 'half',
|
||||
interface: 'icon',
|
||||
},
|
||||
],
|
||||
}));
|
||||
12
src/interfaces/dropdown/readme.md
Normal file
12
src/interfaces/dropdown/readme.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Dropdown
|
||||
|
||||
Pick one from a list of options.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|---------------|----------------------------------------|---------|
|
||||
| `placeholder` | Text to show when no input is entered | `null` |
|
||||
| `allow-none` | Allow the user to deselect the value | `false` |
|
||||
| `allow-other` | Allow the user to enter a custom value | `false` |
|
||||
| `choices` | What choices to present to the user | `null` |
|
||||
@@ -5,6 +5,7 @@ import InterfaceNumeric from './numeric/';
|
||||
import InterfaceSlider from './slider/';
|
||||
import InterfaceToggle from './toggle/';
|
||||
import InterfaceWYSIWYG from './wysiwyg/';
|
||||
import InterfaceDropdown from './dropdown/';
|
||||
|
||||
export const interfaces = [
|
||||
InterfaceTextInput,
|
||||
@@ -14,6 +15,7 @@ export const interfaces = [
|
||||
InterfaceDivider,
|
||||
InterfaceToggle,
|
||||
InterfaceWYSIWYG,
|
||||
InterfaceDropdown,
|
||||
];
|
||||
|
||||
export default interfaces;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
:min="minValue"
|
||||
:max="maxValue"
|
||||
:step="stepInterval"
|
||||
full-width
|
||||
@input="$listeners.input"
|
||||
>
|
||||
<template v-if="iconLeft" #prepend>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
:trim="trim"
|
||||
:type="masked ? 'password' : 'text'"
|
||||
:class="font"
|
||||
full-width
|
||||
@input="$listeners.input"
|
||||
>
|
||||
<template v-if="iconLeft" #prepend><v-icon :name="iconLeft" /></template>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
:disabled="disabled"
|
||||
:class="font"
|
||||
@input="$listeners.input"
|
||||
full-width
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ describe('Interfaces / Toggle', () => {
|
||||
it('Renders a v-checkbox', () => {
|
||||
const component = shallowMount(InterfaceToggle, {
|
||||
localVue,
|
||||
listeners: {
|
||||
input: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(component.find(VCheckbox).exists()).toBe(true);
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"comments": "Comments",
|
||||
|
||||
"item_count": "No Items | One Item | {count} Items",
|
||||
"all_items": "All Items",
|
||||
|
||||
"users": "Users",
|
||||
"files": "Files",
|
||||
@@ -335,6 +336,17 @@
|
||||
"custom_formats": "Custom Formats",
|
||||
"tinymce_options_override": "TinyMCE Options Override",
|
||||
|
||||
"dropdown": "Dropdown",
|
||||
"allow_other": "Allow Other",
|
||||
"allow_none": "Allow None",
|
||||
"choices": "Choices",
|
||||
"use_double_colon_for_key": "Use double colon for dedicated keys, eg: `value_saved::Option Displayed`",
|
||||
"choices_option_configured_incorrectly": "Choices option configured incorrectly",
|
||||
"deselect": "Deselect",
|
||||
"deselect_all": "Deselect All",
|
||||
|
||||
"other": "Other...",
|
||||
|
||||
"about_directus": "About Directus",
|
||||
"activity_log": "Activity Log",
|
||||
"add_field_filter": "Add a field filter",
|
||||
@@ -485,7 +497,6 @@
|
||||
"delete_role_are_you_sure": "Are you sure to delete the role \"{name}\"? This action cannot be undone.",
|
||||
"desc": "desc",
|
||||
"description": "Description",
|
||||
"deselect": "Deselect",
|
||||
"dialog_beginning": "Beginning of dialog window.",
|
||||
"discard_changes": "Discard Changes",
|
||||
"display_name": "Display Name",
|
||||
@@ -673,7 +684,6 @@
|
||||
"operator": "Operator",
|
||||
"optional": "Optional",
|
||||
"options": "Options",
|
||||
"other": "Other",
|
||||
"otp": "One-Time Password",
|
||||
"password": "Password",
|
||||
"password_reset_sending": "Sending email...",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<div class="label type-text">{{ $t('layouts.cards.image_source') }}</div>
|
||||
<v-select
|
||||
v-model="imageSource"
|
||||
full-width
|
||||
allow-null
|
||||
item-value="field"
|
||||
item-text="name"
|
||||
@@ -19,7 +18,6 @@
|
||||
<v-select
|
||||
v-model="imageFit"
|
||||
:disabled="imageSource === null"
|
||||
full-width
|
||||
:items="[
|
||||
{
|
||||
text: $t('layouts.cards.crop'),
|
||||
@@ -35,17 +33,17 @@
|
||||
|
||||
<div class="setting">
|
||||
<div class="label type-text">{{ $t('layouts.cards.title') }}</div>
|
||||
<v-input full-width v-model="title" />
|
||||
<v-input v-model="title" />
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<div class="label type-text">{{ $t('layouts.cards.subtitle') }}</div>
|
||||
<v-input full-width v-model="subtitle" />
|
||||
<v-input v-model="subtitle" />
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<div class="label type-text">{{ $t('layouts.cards.fallback_icon') }}</div>
|
||||
<v-input full-width v-model="icon" />
|
||||
<v-input v-model="icon" />
|
||||
</div>
|
||||
</drawer-detail>
|
||||
</portal>
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
|
||||
<drawer-detail icon="format_line_spacing" :title="$t('layouts.tabular.spacing')">
|
||||
<v-select
|
||||
full-width
|
||||
v-model="tableSpacing"
|
||||
:items="[
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('add_new_folder') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-input :placeholder="$t('folder_name')" v-model="newFolderName" full-width />
|
||||
<v-input :placeholder="$t('folder_name')" v-model="newFolderName" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="dialogActive = false">{{ $t('cancel') }}</v-button>
|
||||
|
||||
@@ -33,17 +33,16 @@
|
||||
<v-tab-item value="collection">
|
||||
<h2 class="type-title">{{ $t('creating_collection_info') }}</h2>
|
||||
<div class="type-label">{{ $t('name') }}</div>
|
||||
<v-input full-width class="monospace" v-model="collectionName" />
|
||||
<v-input class="monospace" v-model="collectionName" />
|
||||
<v-divider />
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="type-label">{{ $t('primary_key_field') }}</div>
|
||||
<v-input full-width class="monospace" v-model="primaryKeyFieldName" />
|
||||
<v-input class="monospace" v-model="primaryKeyFieldName" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="type-label">{{ $t('type') }}</div>
|
||||
<v-select
|
||||
full-width
|
||||
:items="[
|
||||
{
|
||||
text: $t('auto_increment_integer'),
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
readonly
|
||||
@click="toggle"
|
||||
:value="field.name"
|
||||
full-width
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon class="drag-handle" name="drag_indicator" @click.stop />
|
||||
@@ -41,15 +40,10 @@
|
||||
<v-card-title>{{ $t('duplicate_where_to') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<span class="label">{{ $tc('collection', 0) }}</span>
|
||||
<v-select
|
||||
class="monospace"
|
||||
:items="collections"
|
||||
v-model="duplicateTo"
|
||||
full-width
|
||||
/>
|
||||
<v-select class="monospace" :items="collections" v-model="duplicateTo" />
|
||||
|
||||
<span class="label">{{ $tc('field', 0) }}</span>
|
||||
<v-input class="monospace" v-model="duplicateName" full-width />
|
||||
<v-input class="monospace" v-model="duplicateName" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="duplicateActive = false">
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
:disabled="isNew === false"
|
||||
id="name"
|
||||
v-model="_field"
|
||||
full-width
|
||||
:placeholder="$t('enter_field_name')"
|
||||
/>
|
||||
|
||||
|
||||
@@ -16,15 +16,7 @@
|
||||
/>
|
||||
</draggable>
|
||||
|
||||
<v-button
|
||||
class="add-field"
|
||||
align="left"
|
||||
dashed
|
||||
outlined
|
||||
full-width
|
||||
large
|
||||
@click="openFieldSetup()"
|
||||
>
|
||||
<v-button class="add-field" align="left" dashed outlined large @click="openFieldSetup()">
|
||||
<v-icon name="add" />
|
||||
{{ $t('add_field') }}
|
||||
</v-button>
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
<div class="pane-form">
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('host') }}</div>
|
||||
<v-input full-width v-model="_value.db_host" />
|
||||
<v-input v-model="_value.db_host" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('port') }}</div>
|
||||
<v-input type="number" full-width v-model="_value.db_port" />
|
||||
<v-input type="number" v-model="_value.db_port" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('db_user') }}</div>
|
||||
<v-input full-width v-model="_value.db_user" />
|
||||
<v-input v-model="_value.db_user" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('db_password') }}</div>
|
||||
<v-input type="password" full-width v-model="_value.db_password" />
|
||||
<v-input type="password" v-model="_value.db_password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('db_name') }}</div>
|
||||
<v-input full-width v-model="_value.db_name" class="db" />
|
||||
<v-input v-model="_value.db_name" class="db" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('db_type') }}</div>
|
||||
<v-input full-width value="MySQL" disabled />
|
||||
<v-input value="MySQL" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
<div class="pane-form">
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('project_name') }}</div>
|
||||
<v-input full-width v-model="_value.project_name" />
|
||||
<v-input v-model="_value.project_name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('project_key') }}</div>
|
||||
<v-input slug full-width v-model="_value.project" class="key" />
|
||||
<v-input slug v-model="_value.project" class="key" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('admin_email') }}</div>
|
||||
<v-input full-width type="email" v-model="_value.user_email" />
|
||||
<v-input type="email" v-model="_value.user_email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="type-label label">{{ $t('admin_password') }}</div>
|
||||
<v-input full-width type="password" v-model="_value.user_password" />
|
||||
<v-input type="password" v-model="_value.user_password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
@input="setToken"
|
||||
:value="token"
|
||||
:placeholder="$t('super_admin_token')"
|
||||
full-width
|
||||
class="token"
|
||||
>
|
||||
<template #append>
|
||||
|
||||
@@ -6,14 +6,12 @@
|
||||
type="email"
|
||||
v-model="email"
|
||||
:placeholder="$t('email')"
|
||||
full-width
|
||||
/>
|
||||
<v-input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
v-model="password"
|
||||
:placeholder="$t('password')"
|
||||
full-width
|
||||
/>
|
||||
<v-notice danger v-if="error">
|
||||
{{ errorFormatted }}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
type="email"
|
||||
v-model="email"
|
||||
:placeholder="$t('email')"
|
||||
full-width
|
||||
/>
|
||||
<v-notice success v-if="done">{{ $t('password_reset_sent') }}</v-notice>
|
||||
<v-notice danger v-if="error">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<v-input :value="email" disabled full-width />
|
||||
<v-input :value="email" disabled />
|
||||
<v-input
|
||||
:placeholder="$t('password')"
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
type="password"
|
||||
full-width
|
||||
v-model="password"
|
||||
:disabled="done"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface FieldRaw {
|
||||
primary_key: boolean;
|
||||
auto_increment: boolean;
|
||||
default_value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
note: string | null;
|
||||
note: string | TranslateResult | null;
|
||||
signed: boolean;
|
||||
type: string;
|
||||
sort: null | number;
|
||||
|
||||
4
src/utils/parse-choices/index.ts
Normal file
4
src/utils/parse-choices/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import parseChoices from './parse-choices';
|
||||
|
||||
export { parseChoices };
|
||||
export default parseChoices;
|
||||
42
src/utils/parse-choices/parse-choices.test.ts
Normal file
42
src/utils/parse-choices/parse-choices.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import parseChoices from './parse-choices';
|
||||
|
||||
describe('Utils / Parse Choices', () => {
|
||||
it('Filters out empty rows', () => {
|
||||
const choices = `
|
||||
test
|
||||
|
||||
above is gone
|
||||
`;
|
||||
|
||||
const result = parseChoices(choices);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]).toEqual({ text: 'test', value: 'test' });
|
||||
});
|
||||
|
||||
it('Filters out whitespace around options', () => {
|
||||
const choices = ' bunch of whitespace ';
|
||||
|
||||
const result = parseChoices(choices);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({ text: 'bunch of whitespace', value: 'bunch of whitespace' });
|
||||
});
|
||||
|
||||
it('Separates on double colon to form key/value pairs', () => {
|
||||
const choices = `
|
||||
value::Text
|
||||
`;
|
||||
const result = parseChoices(choices);
|
||||
expect(result[0]).toEqual({ text: 'Text', value: 'value' });
|
||||
});
|
||||
|
||||
it('Trims whitespace around colons', () => {
|
||||
const choices = `
|
||||
works :: Yes!
|
||||
`;
|
||||
|
||||
const result = parseChoices(choices);
|
||||
expect(result[0]).toEqual({ text: 'Yes!', value: 'works' });
|
||||
});
|
||||
});
|
||||
21
src/utils/parse-choices/parse-choices.ts
Normal file
21
src/utils/parse-choices/parse-choices.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function parseChoices(choices: string) {
|
||||
return choices
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((r) => r.length !== 0)
|
||||
.map((row) => {
|
||||
const parts = row.split('::').map((part) => part.trim());
|
||||
|
||||
if (parts.length > 1) {
|
||||
return {
|
||||
value: parts[0],
|
||||
text: parts[1],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: parts[0],
|
||||
text: parts[0],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
:placeholder="$t('leave_comment')"
|
||||
v-model="newCommentContent"
|
||||
expand-on-focus
|
||||
full-width
|
||||
>
|
||||
<template #append>
|
||||
<v-button
|
||||
|
||||
@@ -35,7 +35,7 @@ export const basic = () =>
|
||||
<drawer-detail icon="forum" title="Comments">
|
||||
These sections can hold any markup:
|
||||
|
||||
<v-input full-width placeholder="I'm an input" />
|
||||
<v-input placeholder="I'm an input" />
|
||||
</drawer-detail>
|
||||
</drawer-detail-group>`,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<v-menu attached>
|
||||
<template #activator="{ toggle, active }">
|
||||
<v-input @click="toggle" :class="{ active }" readonly value="Add filter" full-width>
|
||||
<v-input @click="toggle" :class="{ active }" readonly value="Add filter">
|
||||
<template #prepend><v-icon name="add" /></template>
|
||||
<template #append><v-icon name="expand_more" /></template>
|
||||
</v-input>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="filter-input">
|
||||
<template v-if="['between', 'nbetween'].includes(operator)">
|
||||
<v-input :type="type" :value="csvValue[0]" @input="setCSV(0, $event)" full-width>
|
||||
<v-input :type="type" :value="csvValue[0]" @input="setCSV(0, $event)">
|
||||
<template #append>
|
||||
<v-icon name="vertical_align_top" />
|
||||
</template>
|
||||
</v-input>
|
||||
<v-input :type="type" :value="csvValue[1]" @input="setCSV(1, $event)" full-width>
|
||||
<v-input :type="type" :value="csvValue[1]" @input="setCSV(1, $event)">
|
||||
<template #append>
|
||||
<v-icon name="vertical_align_bottom" />
|
||||
</template>
|
||||
@@ -19,20 +19,19 @@
|
||||
:value="val"
|
||||
:type="type"
|
||||
@input="setCSV(index, $event)"
|
||||
full-width
|
||||
>
|
||||
<template #append>
|
||||
<v-icon v-if="csvValue.length > 1" name="close" @click="removeCSV(val)" />
|
||||
</template>
|
||||
</v-input>
|
||||
<v-button outlined dashed full-width @click="addCSV">
|
||||
<v-button outlined dashed @click="addCSV">
|
||||
<v-icon name="add" />
|
||||
{{ $t('add_new') }}
|
||||
</v-button>
|
||||
</template>
|
||||
<template v-else-if="['empty', 'nempty'].includes(operator) === false">
|
||||
<v-checkbox v-if="type === 'checkbox'" :inputValue="_value" />
|
||||
<v-input v-else v-model="_value" :type="type" full-width />
|
||||
<v-input v-else v-model="_value" :type="type" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<drawer-detail :icon="currentLayout.icon" :title="$t('layout_type')">
|
||||
<v-select :items="layouts" item-text="name" item-value="id" v-model="viewType" full-width />
|
||||
<v-select :items="layouts" item-text="name" item-value="id" v-model="viewType" />
|
||||
</drawer-detail>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import VDialog from '@/components/v-dialog';
|
||||
import VOverlay from '@/components/v-dialog';
|
||||
import VCard, { VCardTitle, VCardActions } from '@/components/v-card';
|
||||
|
||||
import Tooltip from '@/directives/tooltip';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.use(VueRouter);
|
||||
@@ -26,6 +28,8 @@ localVue.component('v-card-title', VCardTitle);
|
||||
localVue.component('v-card-actions', VCardActions);
|
||||
localVue.component('v-overlay', VOverlay);
|
||||
|
||||
localVue.directive('tooltip', Tooltip);
|
||||
|
||||
describe('Views / Private / Module Bar Avatar', () => {
|
||||
let req: any = {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user