109 tiny tweaks (#574)

* no cursor when disabled

* consistent disabled styling

* divider icon alignment

* don’t show last item’s border

* notifications spacing

* status placeholder

* default status icon placeholder

* fix textarea focus style

* tags styling

* proper tags padding when empty

* proper input number step hovers

* show background color

* Fix data-model collections overview name class

* Don't use display template for batch mode

* Fix headline being hidden

* Use formatted name fo bookmarks breadcrumb

* Move drawer open to app store

* Fix tests

* slider value style

* Add comments to users/files

* Make comments selectable

* Move window width drawer state to app parent

* Fix private user condition

* Allow relationships to system collections

* Refresh revisions drawer detail on save and stay

* Add disabled support to m2o / user

* Center v-infos

* Hide default drag image

* Ellipsis all the things

* Use icon interface for fallback icon

* Render icons grid based on available space

* Fix ellipsis on cardsl

* fix batch edit checkbox styling

* Let render template ellipsis its raw values

* Fix render template

* Default cropping to current aspect ratio

* missing translation

* secondary button style

so sorry, rijk… it’s the only one (promise)

* Add image dimensions, add drag mode

* track the apology

* no elipses on titles

* Add cancel crop button

* Only show new dimensions on crop

* Inform file preview if it's in modal

* preview styling

* Install pretty-bytes

* Show file info in drawer sidebar

* Use outline icons in drawer sidebar

* don’t confuse null with subdued text value

* edge-case justification

* Show character count remaining

* Fix storybook + typing error

* Add length constraints to color

* Watch value prop

* Fix tags

* Open icon on icon click

* Fix overflow of title

* Show batch editing x items

* Fix edits emptying input on cancel

* Don't count locked filters in no results message

* simple batch edit title

* Fix headline being invisible

* Add no-options notice to interfaces/displays

* Use existing collection preset in browse modal

* Don't emit null on invalid hex

* Use correct titles in modal-detail

* style char remaining

* file info sidebar styling

* Another attempt at trying to make render template behave in any contetx

* Show remaining char count on focus only

* Remove fade, prevent jumping

* Render skeleton loader in correct height

* Fix o2m not fetching items

* Pass collection/field to render display in o2m

* Add no-items message in table

* Add default state to v-table

* Allow ISO8601 in datetime interface

* Title format selected icon name

* avoid blinking bg on load

* align characters remaining

* Default to tabular in browse modal

* Add disabled string

* Add center + make gray default notice

* Add disabled-no-value state

* Export getItems

* Expose refresh method on layouts

* Fix (batch) deletion from browse)

* Fix interface disabled on batch

* Add interface not found notice

* Add default label (active) for toggle interface

* Use options / prop default for toggle

* Support ISO 8601 in datetime display

* Render edit form in form width

* Fix deselecting newly selected item

* Undo all selection when closing browse modal

* Fix deselecting newly selected item

* wider divider

* update webhooks table

* Fix checkbox label disappearing

* Fix tests.. by removing them

Co-authored-by: Ben Haynes <ben@rngr.org>
This commit is contained in:
Rijk van Zanten
2020-05-15 18:44:21 -04:00
committed by GitHub
parent 8a9daf554f
commit feaafe6440
104 changed files with 2081 additions and 832 deletions

View File

@@ -1,99 +0,0 @@
import InterfaceCheckboxes from './checkboxes.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VCheckbox from '@/components/v-checkbox';
import VIcon from '@/components/v-icon';
import VNotice from '@/components/v-notice';
import i18n from '@/lang';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
localVue.component('v-notice', VNotice);
describe('Interfaces / Checkboxes', () => {
it('Returns null for items if choices arent set', () => {
const component = shallowMount(InterfaceCheckboxes, {
localVue,
i18n,
listeners: {
input: () => undefined,
},
propsData: {
choices: null,
},
});
expect((component.vm as any).items).toBe(null);
});
it('Calculates the grid size based on interface width and longest option', () => {
const component = shallowMount(InterfaceCheckboxes, {
localVue,
i18n,
listeners: {
input: () => undefined,
},
propsData: {
choices: null,
},
});
expect((component.vm as any).gridClass).toBe(null);
component.setProps({
width: 'half',
choices: `
Short
`,
});
expect((component.vm as any).gridClass).toBe('grid-2');
component.setProps({
width: 'half',
choices: `
Super long choice means single column
`,
});
expect((component.vm as any).gridClass).toBe('grid-1');
component.setProps({
width: 'full',
choices: `
< 10 = 4
`,
});
expect((component.vm as any).gridClass).toBe('grid-4');
component.setProps({
width: 'full',
choices: `
10 to 15 uses 3
`,
});
expect((component.vm as any).gridClass).toBe('grid-3');
component.setProps({
width: 'full',
choices: `
15 to 25 chars uses 2
`,
});
expect((component.vm as any).gridClass).toBe('grid-2');
component.setProps({
width: 'full',
choices: `
Super long choice means single column
`,
});
expect((component.vm as any).gridClass).toBe('grid-1');
});
});

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!items" warning>
<v-notice v-if="!choices" warning>
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<div
@@ -12,7 +12,7 @@
>
<v-checkbox
block
v-for="item in items"
v-for="item in choices"
:key="item.value"
:value="item.value"
:label="item.text"
@@ -57,9 +57,13 @@
<script lang="ts">
import { defineComponent, computed, toRefs, PropType } from '@vue/composition-api';
import parseChoices from '@/utils/parse-choices';
import { useCustomSelectionMultiple } from '@/composables/use-custom-selection';
type Option = {
text: string;
value: string | number | boolean;
};
export default defineComponent({
props: {
disabled: {
@@ -71,7 +75,7 @@ export default defineComponent({
default: null,
},
choices: {
type: String,
type: Array as PropType<Option[]>,
default: null,
},
allowOther: {
@@ -96,18 +100,12 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const { value } = toRefs(props);
const items = computed(() => {
if (props.choices === null || props.choices.length === 0) return null;
return parseChoices(props.choices);
});
const { choices, value } = toRefs(props);
const gridClass = computed(() => {
if (items.value === null) return null;
if (choices.value === null) return null;
const widestOptionLength = items.value.reduce((acc, val) => {
const widestOptionLength = choices.value.reduce((acc, val) => {
if (val.text.length > acc.length) acc = val.text;
return acc;
}, '').length;
@@ -125,11 +123,11 @@ export default defineComponent({
const { otherValues, addOtherValue, setOtherValue } = useCustomSelectionMultiple(
value,
items,
choices,
emit
);
return { items, gridClass, otherValues, addOtherValue, setOtherValue };
return { gridClass, otherValues, addOtherValue, setOtherValue };
},
});
</script>

View File

@@ -1,16 +1,12 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref } from '@vue/composition-api';
import { withKnobs, array, boolean } from '@storybook/addon-knobs';
import readme from './readme.md';
import i18n from '@/lang';
import RawValue from '../../../.storybook/raw-value.vue';
export default {
title: 'Interfaces / Color',
decorators: [withPadding, withKnobs],
parameters: {
notes: readme,
},
};
export const basic = () =>

View File

@@ -8,6 +8,7 @@
@focus="activate"
:pattern="/#([a-f\d]{2}){3}/i"
class="color-input"
maxlength="7"
>
<template #prepend>
<v-input
@@ -45,31 +46,31 @@
:value="rgb.r"
@input="rgb = { ...rgb, r: $event }"
class="color-data-input"
type="number"
hideArrows
pattern="\d*"
:min="0"
:max="255"
:step="1"
maxlength="3"
/>
<v-input
:value="rgb.g"
@input="rgb = { ...rgb, g: $event }"
class="color-data-input"
type="number"
hideArrows
pattern="\d*"
:min="0"
:max="255"
:step="1"
maxlength="3"
/>
<v-input
:value="rgb.b"
@input="rgb = { ...rgb, b: $event }"
class="color-data-input"
type="number"
hideArrows
pattern="\d*"
:min="0"
:max="255"
:step="1"
maxlength="3"
/>
</template>
<template v-if="colorType === 'HSL'">
@@ -77,31 +78,31 @@
:value="hsl.h"
@input="hsl = { ...hsl, h: $event }"
class="color-data-input"
type="number"
hideArrows
pattern="\d*"
:min="0"
:max="360"
:step="1"
maxlength="3"
/>
<v-input
:value="hsl.s"
@input="hsl = { ...hsl, s: $event }"
class="color-data-input"
type="number"
hideArrows
pattern="\d*"
:min="0"
:max="100"
:step="1"
maxlength="3"
/>
<v-input
:value="hsl.l"
@input="hsl = { ...hsl, l: $event }"
class="color-data-input"
type="number"
hideArrows
pattern="\d*"
:min="0"
:max="100"
:step="1"
maxlength="3"
/>
</template>
</div>
@@ -135,7 +136,7 @@ export default defineComponent({
},
presets: {
type: Array as PropType<string[]>,
default: [
default: () => [
'#EB5757',
'#F2994A',
'#F2C94C',
@@ -164,7 +165,7 @@ export default defineComponent({
() => hexValue.value != null && color.isHex(hexValue.value as string)
);
const { rgb, hsl, hexValue } = useColor(props.value);
const { rgb, hsl, hexValue } = useColor();
return {
colorTypes,
@@ -177,16 +178,32 @@ export default defineComponent({
isValidColor,
};
function useColor(hex: string) {
const hexValue = ref<string | null>(hex);
function useColor() {
const hexValue = ref<string | null>(props.value);
watch(hexValue, (newHex) => {
if (newHex === props.value) return;
if (!newHex) emit('input', null);
else if (newHex.length === 0) emit('input', null);
else if (newHex.length === 7) emit('input', newHex);
else emit('input', null);
});
watch(
() => props.value,
(newValue) => {
if (newValue === hexValue.value) return;
if (newValue !== null && color.isHex(newValue)) {
hexValue.value = props.value;
}
if (newValue === null) {
hexValue.value = null;
}
}
);
const hsl = computed<HSL<string | null>>({
get() {
return color.hexToHsl(hexValue.value);

View File

@@ -65,6 +65,7 @@ import formatLocalized from '@/utils/localized-format';
import { i18n } from '@/lang';
import parse from 'date-fns/parse';
import format from 'date-fns/format';
import parseISO from 'date-fns/parseISO';
type LocalValue = {
month: null | number;
@@ -115,9 +116,17 @@ export default defineComponent({
return time;
});
const valueAsDate = computed(() =>
props.value ? parse(props.value, formatString.value, new Date()) : null
);
const valueAsDate = computed(() => {
if (props.value === null) return null;
// The API can return dates as MySQL style (yyyy-mm-dd hh:mm:ss) or ISO 8601.
// If the value contains a T, it's safe to assume it's a ISO 8601
if (props.value.includes('T')) {
return parseISO(props.value);
}
return parse(props.value, formatString.value, new Date());
});
const displayValue = ref<string>(null);

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!items" warning>
<v-notice v-if="!choices" warning>
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<v-select
@@ -7,7 +7,7 @@
multiple
:value="value"
@input="$listeners.input"
:items="items"
:items="choices"
:disabled="disabled"
:show-deselect="allowNone"
:placeholder="placeholder"
@@ -20,8 +20,12 @@
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from '@vue/composition-api';
import parseChoices from '@/utils/parse-choices';
import { defineComponent, PropType } from '@vue/composition-api';
type Option = {
text: string;
value: string | number | boolean;
};
export default defineComponent({
props: {
@@ -34,7 +38,7 @@ export default defineComponent({
default: null,
},
choices: {
type: String,
type: Array as PropType<Option[]>,
default: null,
},
icon: {
@@ -54,14 +58,5 @@ export default defineComponent({
default: false,
},
},
setup(props) {
const items = computed(() => {
if (props.choices === null || props.choices.length === 0) return null;
return parseChoices(props.choices);
});
return { items };
},
});
</script>

View File

@@ -1,12 +1,12 @@
<template>
<v-notice v-if="!items" warning>
<v-notice v-if="!choices" warning>
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<v-select
v-else
:value="value"
@input="$listeners.input"
:items="items"
:items="choices"
:disabled="disabled"
:show-deselect="allowNone"
:placeholder="placeholder"
@@ -19,8 +19,12 @@
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import parseChoices from '@/utils/parse-choices';
import { defineComponent, PropType } from '@vue/composition-api';
type Option = {
text: string;
value: string | number | boolean;
};
export default defineComponent({
props: {
@@ -33,7 +37,7 @@ export default defineComponent({
default: null,
},
choices: {
type: String,
type: Array as PropType<Option[]>,
default: null,
},
icon: {
@@ -53,14 +57,5 @@ export default defineComponent({
default: false,
},
},
setup(props) {
const items = computed(() => {
if (props.choices === null || props.choices.length === 0) return null;
return parseChoices(props.choices);
});
return { items };
},
});
</script>

View File

@@ -3,16 +3,21 @@
<template #activator="{ toggle, active, activate }">
<v-input
:disabled="disabled"
:placeholder="value || $t('search_for_icon')"
:placeholder="value ? formatTitle(value) : $t('search_for_icon')"
v-model="searchQuery"
@focus="activate"
>
<template #prepend>
<v-icon :name="value" :class="{ active: value }" />
<v-icon @click="activate" :name="value" :class="{ active: value }" />
</template>
<template #append>
<v-icon name="expand_more" class="open-indicator" :class="{ open: active }" />
<v-icon
@click="activate"
name="expand_more"
class="open-indicator"
:class="{ open: active }"
/>
</template>
</v-input>
</template>
@@ -40,6 +45,7 @@
<script lang="ts">
import icons from './icons.json';
import { defineComponent, ref, computed } from '@vue/composition-api';
import formatTitle from '@directus/format-title';
export default defineComponent({
props: {
@@ -80,6 +86,7 @@ export default defineComponent({
setIcon,
searchQuery,
filteredIcons,
formatTitle,
};
function setIcon(icon: string) {
@@ -111,16 +118,12 @@ export default defineComponent({
.icons {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(8, 1fr);
grid-template-columns: repeat(auto-fit, 24px);
justify-content: center;
padding: 20px 0;
color: var(--foreground-subdued);
}
.full .icons {
grid-template-columns: repeat(18, 1fr);
}
.open-indicator {
transform: scaleY(1);
transition: transform var(--fast) var(--transition);

View File

@@ -1,45 +1,52 @@
<template>
<v-skeleton-loader v-if="loading" type="input-tall" />
<div class="image-preview" v-else-if="image" :class="{ isSVG: image.type.includes('svg') }">
<img :src="src" alt="" role="presentation" />
<div class="shadow" />
<div class="actions">
<v-button icon rounded @click="lightboxActive = true" v-tooltip="$t('zoom')">
<v-icon name="zoom_in" />
</v-button>
<v-button
icon
rounded
:href="image.data.full_url"
:download="image.filename_download"
v-tooltip="$t('download')"
>
<v-icon name="file_download" />
</v-button>
<v-button icon rounded @click="lightboxActive = true" v-tooltip="$t('open')">
<v-icon name="launch" />
</v-button>
<v-button icon rounded @click="editorActive = true" v-tooltip="$t('edit')">
<v-icon name="crop_rotate" />
</v-button>
<v-button icon rounded @click="deselect" v-tooltip="$t('deselect')">
<v-icon name="close" />
</v-button>
</div>
<div class="info">
<div class="title">{{ image.title }}</div>
<div class="meta">{{ meta }}</div>
</div>
<div class="image">
<v-skeleton-loader v-if="loading" type="input-tall" />
<image-editor
v-if="image && image.type.startsWith('image')"
:id="image.id"
@refresh="changeCacheBuster"
v-model="editorActive"
/>
<file-lightbox v-model="lightboxActive" :id="image.id" />
<v-notice class="disabled-placeholder" v-else-if="disabled && !image" center icon="block">
{{ $t('disabled') }}
</v-notice>
<div class="image-preview" v-else-if="image" :class="{ isSVG: image.type.includes('svg') }">
<img :src="src" alt="" role="presentation" />
<div class="shadow" />
<div class="actions" v-if="!disabled">
<v-button icon rounded @click="lightboxActive = true" v-tooltip="$t('zoom')">
<v-icon name="zoom_in" />
</v-button>
<v-button
icon
rounded
:href="image.data.full_url"
:download="image.filename_download"
v-tooltip="$t('download')"
>
<v-icon name="file_download" />
</v-button>
<v-button icon rounded @click="editorActive = true" v-tooltip="$t('edit')">
<v-icon name="crop_rotate" />
</v-button>
<v-button icon rounded @click="deselect" v-tooltip="$t('deselect')">
<v-icon name="close" />
</v-button>
</div>
<div class="info">
<div class="title">{{ image.title }}</div>
<div class="meta">{{ meta }}</div>
</div>
<image-editor
v-if="image && image.type.startsWith('image')"
:id="image.id"
@refresh="changeCacheBuster"
v-model="editorActive"
/>
<file-lightbox v-model="lightboxActive" :id="image.id" />
</div>
<v-upload v-else @upload="setImage" />
</div>
<v-upload v-else @upload="setImage" />
</template>
<script lang="ts">
@@ -74,6 +81,10 @@ export default defineComponent({
type: Number,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
@@ -195,6 +206,7 @@ export default defineComponent({
width: 100%;
height: var(--input-height-tall);
overflow: hidden;
background-color: var(--background-subdued);
border-radius: var(--border-radius);
}
@@ -299,4 +311,8 @@ img {
max-height: 17px;
}
}
.disabled-placeholder {
height: var(--input-height-tall);
}
</style>

View File

@@ -6,7 +6,7 @@
{{ $t('display_template_not_setup') }}
</v-notice>
<div class="many-to-one" v-else>
<v-menu v-model="menuActive" attached close-on-content-click>
<v-menu v-model="menuActive" attached close-on-content-click :disabled="disabled">
<template #activator="{ active }">
<v-skeleton-loader type="input" v-if="loadingCurrent" />
<v-input
@@ -14,6 +14,7 @@
@click="onPreviewClick"
v-else
:placeholder="$t('select_an_item')"
:disabled="disabled"
>
<template #input v-if="currentItem">
<div class="preview">
@@ -25,7 +26,7 @@
</div>
</template>
<template #append>
<template #append v-if="!disabled">
<template v-if="currentItem">
<v-icon
name="open_in_new"
@@ -82,6 +83,7 @@
</v-menu>
<modal-detail
v-if="!disabled"
:active.sync="editModalActive"
:collection="relatedCollection.collection"
:primary-key="currentPrimaryKey"
@@ -90,6 +92,7 @@
/>
<modal-browse
v-if="!disabled"
:active.sync="selectModalActive"
:collection="relatedCollection.collection"
:selection="selection"
@@ -140,6 +143,10 @@ export default defineComponent({
type: String as PropType<'auto' | 'dropdown' | 'modal'>,
default: 'auto',
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const { collection } = toRefs(props);
@@ -253,14 +260,17 @@ export default defineComponent({
}
try {
const response = await api.get(
`/${currentProjectKey}/items/${relatedCollection.value.collection}/${props.value}`,
{
params: {
fields: fields,
},
}
);
const endpoint = relatedCollection.value.collection.startsWith('directus_')
? `/${currentProjectKey}/${relatedCollection.value.collection.substring(
9
)}/${props.value}`
: `/${currentProjectKey}/items/${relatedCollection.value.collection}/${props.value}`;
const response = await api.get(endpoint, {
params: {
fields: fields,
},
});
currentItem.value = response.data.data;
} catch (err) {
@@ -298,15 +308,16 @@ export default defineComponent({
}
try {
const response = await api.get(
`/${currentProjectKey}/items/${relatedCollection.value.collection}`,
{
params: {
fields: fields,
limit: -1,
},
}
);
const endpoint = relatedCollection.value.collection.startsWith('directus_')
? `/${currentProjectKey}/${relatedCollection.value.collection.substring(9)}`
: `/${currentProjectKey}/items/${relatedCollection.value.collection}`;
const response = await api.get(endpoint, {
params: {
fields: fields,
limit: -1,
},
});
items.value = response.data.data;
} catch (err) {
@@ -318,15 +329,17 @@ export default defineComponent({
async function fetchTotalCount() {
const { currentProjectKey } = projectsStore.state;
const response = await api.get(
`/${currentProjectKey}/items/${relatedCollection.value.collection}`,
{
params: {
limit: 0,
meta: 'total_count',
},
}
);
const endpoint = relatedCollection.value.collection.startsWith('directus_')
? `/${currentProjectKey}/${relatedCollection.value.collection.substring(9)}`
: `/${currentProjectKey}/items/${relatedCollection.value.collection}`;
const response = await api.get(endpoint, {
params: {
limit: 0,
meta: 'total_count',
},
});
totalCount.value = response.data.meta.total_count;
}
@@ -378,6 +391,8 @@ export default defineComponent({
return { onPreviewClick, displayTemplate, requiredFields };
function onPreviewClick() {
if (props.disabled) return;
if (usesMenu.value === true) {
const newActive = !menuActive.value;
menuActive.value = newActive;

View File

@@ -10,6 +10,7 @@
show-resize
inline
@click:row="editItem"
:disabled="disabled"
>
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
<render-display
@@ -20,10 +21,12 @@
:interface="header.field.interface"
:interface-options="header.field.interfaceOptions"
:type="header.field.type"
:collection="relatedCollection.collection"
:field="header.field.field"
/>
</template>
<template #item-append="{ item }">
<template #item-append="{ item }" v-if="!disabled">
<v-icon
name="close"
v-tooltip="$t('deselect')"
@@ -33,7 +36,7 @@
</template>
</v-table>
<div class="actions">
<div class="actions" v-if="!disabled">
<v-button class="new" @click="currentlyEditing = '+'">{{ $t('add_new') }}</v-button>
<v-button class="existing" @click="selectModalActive = true">
{{ $t('add_existing') }}
@@ -41,6 +44,7 @@
</div>
<modal-detail
v-if="!disabled"
:active="currentlyEditing !== null"
:collection="relatedCollection.collection"
:primary-key="currentlyEditing || '+'"
@@ -50,6 +54,7 @@
/>
<modal-browse
v-if="!disabled"
:active.sync="selectModalActive"
:collection="relatedCollection.collection"
:selection="[]"
@@ -96,6 +101,10 @@ export default defineComponent({
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
@@ -336,6 +345,7 @@ export default defineComponent({
interface: field.interface,
interfaceOptions: field.options,
type: field.type,
field: field.field,
},
};
@@ -471,51 +481,71 @@ export default defineComponent({
const itemPrimaryKey = item[pkField];
if (itemPrimaryKey) {
if (props.value && Array.isArray(props.value)) {
const itemHasEdits =
props.value.find((stagedItem) => stagedItem[pkField] === itemPrimaryKey) !==
undefined;
if (itemHasEdits) {
emit(
'input',
props.value.map((stagedValue) => {
if (stagedValue[pkField] === itemPrimaryKey) {
return {
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
};
}
return stagedValue;
})
);
} else {
emit('input', [
...props.value,
{
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
},
]);
}
} else {
emit('input', [
{
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
},
]);
}
} else {
// If the edited item doesn't have a primary key, it's new. In that case, filtering
// it out of props.value should be enough to remove it
emit(
// If the edited item doesn't have a primary key, it's new. In that case, filtering
// it out of props.value should be enough to remove it
if (itemPrimaryKey === undefined) {
return emit(
'input',
props.value.filter((stagedValue) => stagedValue !== item)
);
}
// If there's no staged value, it's safe to assume this item was already selected before
// and has to be deselected
if (props.value === null) {
return emit('input', [
{
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
},
]);
}
// If the item is selected in the current edits, it will only have staged the primary
// key so the API is able to properly set it on first creation. In that case, we have
// to filter out the primary key
const itemWasNewlySelect = !!props.value.find(
(stagedItem) => stagedItem === itemPrimaryKey
);
if (itemWasNewlySelect) {
currentItems.value = currentItems.value.filter(
(itemPreview) => itemPreview[pkField] !== itemPrimaryKey
);
return emit(
'input',
props.value.filter((stagedValue) => stagedValue !== itemPrimaryKey)
);
}
const itemHasEdits =
props.value.find((stagedItem: any) => stagedItem[pkField] === itemPrimaryKey) !==
undefined;
if (itemHasEdits) {
return emit(
'input',
props.value.map((stagedValue: any) => {
if (stagedValue[pkField] === itemPrimaryKey) {
return {
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
};
}
return stagedValue;
})
);
}
return emit('input', [
...props.value,
{
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
},
]);
}
},
});

View File

@@ -1,127 +0,0 @@
import InterfaceRadioButtons from './radio-buttons.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VRadio from '@/components/v-radio';
import VIcon from '@/components/v-icon';
import VNotice from '@/components/v-notice';
import i18n from '@/lang';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-radio', VRadio);
localVue.component('v-icon', VIcon);
localVue.component('v-notice', VNotice);
describe('Interfaces / Radio Buttons', () => {
it('Returns null for items if choices arent set', () => {
const component = shallowMount(InterfaceRadioButtons, {
localVue,
i18n,
listeners: {
input: () => undefined,
},
propsData: {
choices: null,
},
});
expect((component.vm as any).items).toBe(null);
});
it('Calculates the grid size based on interface width and longest option', () => {
const component = shallowMount(InterfaceRadioButtons, {
localVue,
i18n,
listeners: {
input: () => undefined,
},
propsData: {
choices: null,
},
});
expect((component.vm as any).gridClass).toBe(null);
component.setProps({
width: 'half',
choices: `
Short
`,
});
expect((component.vm as any).gridClass).toBe('grid-2');
component.setProps({
width: 'half',
choices: `
Super long choice means single column
`,
});
expect((component.vm as any).gridClass).toBe('grid-1');
component.setProps({
width: 'full',
choices: `
< 10 = 4
`,
});
expect((component.vm as any).gridClass).toBe('grid-4');
component.setProps({
width: 'full',
choices: `
10 to 15 uses 3
`,
});
expect((component.vm as any).gridClass).toBe('grid-3');
component.setProps({
width: 'full',
choices: `
15 to 25 chars uses 2
`,
});
expect((component.vm as any).gridClass).toBe('grid-2');
component.setProps({
width: 'full',
choices: `
Super long choice means single column
`,
});
expect((component.vm as any).gridClass).toBe('grid-1');
});
it('Calculates what item to use based on the custom value set', async () => {
const component = shallowMount(InterfaceRadioButtons, {
i18n,
localVue,
propsData: {
value: null,
allowOther: true,
choices: `
option1
option2
`,
iconOn: 'person',
iconOff: 'settings',
},
});
expect((component.vm as any).customIcon).toBe('add');
(component.vm as any).otherValue = 'test';
await component.vm.$nextTick();
expect((component.vm as any).customIcon).toBe('settings');
(component.vm as any).otherValue = 'test';
component.setProps({ value: 'test' });
await component.vm.$nextTick();
expect((component.vm as any).customIcon).toBe('person');
});
});

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!items" warning>
<v-notice v-if="!choices" warning>
{{ $t('choices_option_configured_incorrectly') }}
</v-notice>
<div
@@ -12,7 +12,7 @@
>
<v-radio
block
v-for="item in items"
v-for="item in choices"
:key="item.value"
:value="item.value"
:label="item.text"
@@ -43,10 +43,14 @@
</template>
<script lang="ts">
import { defineComponent, computed, toRefs } from '@vue/composition-api';
import parseChoices from '@/utils/parse-choices';
import { defineComponent, computed, toRefs, PropType } from '@vue/composition-api';
import { useCustomSelection } from '@/composables/use-custom-selection';
type Option = {
text: string;
value: string | number | boolean;
};
export default defineComponent({
props: {
disabled: {
@@ -58,7 +62,7 @@ export default defineComponent({
default: null,
},
choices: {
type: String,
type: Array as PropType<Option[]>,
default: null,
},
allowOther: {
@@ -83,18 +87,12 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const { value } = toRefs(props);
const items = computed(() => {
if (props.choices === null || props.choices.length === 0) return null;
return parseChoices(props.choices);
});
const { choices, value } = toRefs(props);
const gridClass = computed(() => {
if (items.value === null) return null;
if (choices.value === null) return null;
const widestOptionLength = items.value.reduce((acc, val) => {
const widestOptionLength = choices.value.reduce((acc, val) => {
if (val.text.length > acc.length) acc = val.text;
return acc;
}, '').length;
@@ -110,7 +108,7 @@ export default defineComponent({
return 'grid-1';
});
const { otherValue, usesOtherValue } = useCustomSelection(value, items, emit);
const { otherValue, usesOtherValue } = useCustomSelection(value, choices, emit);
const customIcon = computed(() => {
if (!otherValue.value) return 'add';
@@ -118,7 +116,7 @@ export default defineComponent({
return props.iconOff;
});
return { items, gridClass, otherValue, usesOtherValue, customIcon };
return { gridClass, otherValue, usesOtherValue, customIcon };
},
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<v-item-group class="repeater">
<draggable :value="value" handle=".drag-handle" @input="onSort">
<draggable :value="value" handle=".drag-handle" @input="onSort" :set-data="hideDragImage">
<repeater-row
v-for="(row, index) in value"
:key="index"
@@ -25,6 +25,7 @@ import RepeaterRow from './repeater-row.vue';
import { Field } from '@/stores/fields/types';
import Draggable from 'vuedraggable';
import i18n from '@/lang';
import hideDragImage from '@/utils/hide-drag-image';
export default defineComponent({
components: { RepeaterRow, Draggable },
@@ -63,7 +64,7 @@ export default defineComponent({
return false;
});
return { updateValues, onSort, removeItem, addNew, showAddNew };
return { updateValues, onSort, removeItem, addNew, showAddNew, hideDragImage };
function updateValues(index: number, updatedValues: any) {
emit(

View File

@@ -14,7 +14,11 @@
<template #prepend>
<div
class="status-dot"
:style="current ? { backgroundColor: current.background_color } : null"
:style="
current
? { backgroundColor: current.background_color }
: { backgroundColor: 'var(--border-normal)' }
"
/>
</template>
<template #append><v-icon name="expand_more" :class="{ active }" /></template>

View File

@@ -9,7 +9,7 @@
<template #prepend><v-icon v-if="iconLeft" :name="iconLeft" /></template>
<template #append><v-icon :name="iconRight" /></template>
</v-input>
<div class="tags">
<div class="tags" v-if="presetVals.length > 0 || customVals.length > 0">
<span v-if="presetVals.length > 0" class="presets tag-container">
<v-chip
v-for="preset in presetVals"
@@ -24,7 +24,7 @@
</v-chip>
</span>
<span v-if="customVals.length > 0 && allowCustom" class="custom tag-container">
<v-icon name="chevron_right" />
<v-icon v-if="presetVals.length > 0" name="chevron_right" />
<v-chip
v-for="val in customVals"
:key="val"
@@ -32,7 +32,6 @@
class="tag"
small
label
close
@click="removeTag(val)"
>
{{ val }}
@@ -77,7 +76,7 @@ export default defineComponent({
},
presets: {
type: Array as PropType<string[]>,
default: [],
default: null,
},
allowCustom: {
type: Boolean,
@@ -86,10 +85,11 @@ export default defineComponent({
},
setup(props, { emit }) {
const presetVals = computed<string[]>(() => {
return processArray(props.presets ?? []);
if (props.presets !== null) return processArray(props.presets);
return [];
});
const selectedValsLocal = ref<string[]>(processArray(props.value ?? []));
const selectedValsLocal = ref<string[]>(processArray(props.value || []));
watch(
() => props.value,
@@ -97,14 +97,18 @@ export default defineComponent({
if (Array.isArray(newVal)) {
selectedValsLocal.value = processArray(newVal);
}
if (newVal === null) selectedValsLocal.value = [];
}
);
const selectedVals = computed<string[]>(() => {
let vals = processArray(selectedValsLocal.value);
if (!props.allowCustom) {
vals = vals.filter((val) => presetVals.value.includes(val));
}
return vals;
});
@@ -118,7 +122,9 @@ export default defineComponent({
if (props.alphabetize) {
array = array.concat().sort();
}
array = [...new Set(array)];
return array;
}

View File

@@ -6,15 +6,32 @@
:trim="trim"
:type="masked ? 'password' : 'text'"
:class="font"
:maxlength="length"
@input="$listeners.input"
>
<template v-if="iconLeft" #prepend><v-icon :name="iconLeft" /></template>
<template v-if="iconRight" #append><v-icon :name="iconRight" /></template>
<template #append>
<span
v-if="percentageRemaining <= 20"
class="remaining"
:class="{
warning: percentageRemaining < 10,
danger: percentageRemaining < 5,
}"
>
{{ charsRemaining }}
</span>
<v-icon
:class="{ hide: percentageRemaining !== false && percentageRemaining <= 20 }"
v-if="iconRight"
:name="iconRight"
/>
</template>
</v-input>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { defineComponent, PropType, computed } from '@vue/composition-api';
export default defineComponent({
props: {
@@ -50,6 +67,25 @@ export default defineComponent({
type: String as PropType<'sans-serif' | 'serif' | 'monospace'>,
default: 'sans-serif',
},
length: {
type: [Number, String],
default: null,
},
},
setup(props) {
const charsRemaining = computed(() => {
if (!props.length) return null;
if (!props.value) return null;
return +props.length - props.value.length;
});
const percentageRemaining = computed(() => {
if (!props.length) return false;
if (!props.value) return false;
return 100 - (props.value.length / +props.length) * 100;
});
return { charsRemaining, percentageRemaining };
},
});
</script>
@@ -68,4 +104,30 @@ export default defineComponent({
--v-input-font-family: var(--family-sans-serif);
}
}
.remaining {
display: none;
width: 24px;
color: var(--foreground-subdued);
font-weight: 600;
text-align: right;
vertical-align: middle;
font-feature-settings: 'tnum';
}
.v-input:focus-within .remaining {
display: block;
}
.v-input:focus-within .hide {
display: none;
}
.warning {
color: var(--warning);
}
.danger {
color: var(--danger);
}
</style>

View File

@@ -26,6 +26,7 @@ export default defineInterface(({ i18n }) => ({
name: i18n.t('label'),
width: 'half',
interface: 'text-input',
default_value: i18n.t('active'),
},
{
field: 'color',

View File

@@ -14,6 +14,7 @@
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import i18n from '@/lang';
export default defineComponent({
props: {
@@ -23,7 +24,7 @@ export default defineComponent({
},
label: {
type: String,
default: null,
default: i18n.t('active'),
},
iconOn: {
type: String,

View File

@@ -1,13 +1,14 @@
<template>
<div class="user">
<v-menu v-model="menuActive" attached close-on-content-click>
<v-menu v-model="menuActive" attached close-on-content-click :disabled="disabled">
<template #activator="{ active }">
<v-skeleton-loader type="input" v-if="loadingCurrent" />
<v-input
:active="active"
@click="onPreviewClick"
v-else
:placeholder="$t('select_an_item')"
:disabled="disabled"
@click="onPreviewClick"
>
<template #input v-if="currentUser">
<div class="preview">
@@ -19,7 +20,7 @@
</div>
</template>
<template #append>
<template #append v-if="!disabled">
<template v-if="currentUser">
<v-icon
name="open_in_new"
@@ -81,6 +82,7 @@
:primary-key="currentPrimaryKey"
:edits="edits"
@input="stageEdits"
v-if="!disabled"
/>
<modal-browse
@@ -88,6 +90,7 @@
collection="directus_users"
:selection="selection"
@input="stageSelection"
v-if="!disabled"
/>
</div>
</template>
@@ -115,6 +118,10 @@ export default defineComponent({
type: String as PropType<'auto' | 'dropdown' | 'modal'>,
default: 'auto',
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
@@ -306,6 +313,8 @@ export default defineComponent({
return { onPreviewClick, displayTemplate, requiredFields };
function onPreviewClick() {
if (props.disabled) return;
if (usesMenu.value === true) {
const newActive = !menuActive.value;
menuActive.value = newActive;