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

@@ -16,6 +16,7 @@ import { defineComponent, toRefs, watch, computed } from '@vue/composition-api';
import { useAppStore } from '@/stores/app';
import { useUserStore } from '@/stores/user';
import { useProjectsStore } from '@/stores/projects';
import useWindowSize from '@/composables/use-window-size';
export default defineComponent({
setup() {
@@ -23,7 +24,7 @@ export default defineComponent({
const userStore = useUserStore();
const projectsStore = useProjectsStore();
const { hydrating } = toRefs(appStore.state);
const { hydrating, drawerOpen } = toRefs(appStore.state);
const brandStyle = computed(() => {
return {
@@ -31,6 +32,19 @@ export default defineComponent({
};
});
const { width } = useWindowSize();
watch(width, (newWidth, oldWidth) => {
if (newWidth === null || newWidth === 0) return;
if (newWidth === oldWidth) return;
if (newWidth >= 1424) {
if (drawerOpen.value === false) drawerOpen.value = true;
} else {
if (drawerOpen.value === true) drawerOpen.value = false;
}
});
watch(
() => userStore.state.currentUser,
(newUser) => {

View File

@@ -153,7 +153,7 @@ body {
--v-button-background-color-activated: var(--primary);
--v-button-background-color-disabled: var(--background-normal);
--v-button-font-size: 16px;
--v-button-font-weight: 500;
--v-button-font-weight: 600;
--v-button-line-height: 22px;
--v-button-min-width: 140px;
}
@@ -168,7 +168,7 @@ body {
--v-button-color: var(--foreground-normal);
--v-button-color-hover: var(--foreground-normal);
--v-button-color-activated: var(--foreground-normal);
--v-button-background-color: var(--background-normal-alt);
--v-button-background-color: var(--border-subdued); // I'm so sorry! 🥺
--v-button-background-color-hover: var(--background-normal-alt);
--v-button-background-color-activated: var(--background-normal-alt);
}

View File

@@ -179,13 +179,17 @@ body {
position: absolute;
top: 0;
left: 0;
z-index: -1;
z-index: 0;
width: 100%;
height: 100%;
background-color: var(--background-subdued);
border-radius: var(--border-radius);
content: '';
}
> * {
z-index: 1;
}
}
&:not(:disabled):not(.indeterminate).checked {

View File

@@ -56,7 +56,13 @@ body {
}
span.wrapper {
display: flex;
margin-right: 16px;
color: var(--v-divider-label-color);
.v-icon {
margin-right: 4px;
}
}
.type-text {

View File

@@ -6,7 +6,9 @@
}"
>
<v-skeleton-loader v-if="loading && field.hideLoader !== true" />
<component
v-if="interfaceExists"
:is="`interface-${field.interface}`"
v-bind="field.options"
:disabled="disabled"
@@ -16,14 +18,20 @@
:collection="field.collection"
:field="field.field"
:primary-key="primaryKey"
:length="field.length"
@input="$emit('input', $event)"
/>
<v-notice v-else warning>
{{ $t('interface_not_found', { interface: field.interface }) }}
</v-notice>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import interfaces from '@/interfaces';
export default defineComponent({
props: {
@@ -56,6 +64,13 @@ export default defineComponent({
default: false,
},
},
setup(props) {
const interfaceExists = computed(() => {
return !!interfaces.find((inter) => inter.id === props.field.interface);
});
return { interfaceExists };
},
});
</script>

View File

@@ -61,7 +61,9 @@ export default defineComponent({
}
.v-checkbox {
height: 18px; // Don't push down label with normal icon height (24px)
margin-right: 4px;
transform: translateY(-2px);
}
.required {

View File

@@ -35,6 +35,7 @@
:batch-mode="batchMode"
:batch-active="batchActive"
:disabled="isDisabled"
:primary-key="primaryKey"
@input="$emit('input', $event)"
/>

View File

@@ -189,7 +189,7 @@ export default defineComponent({
const gridClass = computed<string | null>(() => {
if (el.value === null) return null;
if (width.value > 612 && width.value <= 700) {
if (width.value > 612 && width.value <= 792) {
return 'grid';
} else {
return 'grid with-fill';

View File

@@ -1,5 +1,5 @@
<template>
<div class="v-info" :class="type">
<div class="v-info" :class="[type, { center }]">
<div class="icon">
<v-icon large :name="icon" />
</div>
@@ -26,6 +26,10 @@ export default defineComponent({
type: String as PropType<'info' | 'success' | 'warning' | 'danger'>,
default: 'info',
},
center: {
type: Boolean,
default: false,
},
},
});
</script>
@@ -80,4 +84,11 @@ export default defineComponent({
margin-bottom: 32px;
}
}
.center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>

View File

@@ -2,7 +2,7 @@
<div
class="v-input"
@click="$emit('click', $event)"
:class="{ 'full-width': fullWidth, 'has-click': hasClick }"
:class="{ 'full-width': fullWidth, 'has-click': hasClick, disabled: disabled }"
>
<div v-if="$slots['prepend-outer']" class="prepend-outer">
<slot name="prepend-outer" :value="value" :disabled="disabled" />
@@ -273,6 +273,10 @@ body {
display: block;
&:hover:not(.disabled) {
--arrow-color: var(--primary);
}
&:active:not(.disabled) {
transform: scale(0.9);
}
@@ -294,7 +298,7 @@ body {
&:focus-within,
&.active {
--arrow-color: var(--primary);
--arrow-color: var(--border-normal-alt);
color: var(--foreground-normal);
background-color: var(--background-page);
@@ -354,6 +358,11 @@ body {
&.has-click {
cursor: pointer;
&.disabled {
cursor: auto;
}
input {
pointer-events: none;
.prefix,

View File

@@ -4,7 +4,7 @@
<slot name="activator" v-bind="{ on }" />
</template>
<article class="v-modal">
<article class="v-modal" :class="{ 'form-width': formWidth }">
<header class="header">
<v-icon class="menu-toggle" name="menu" @click="sidebarActive = !sidebarActive" />
<h2 class="title">{{ title }}</h2>
@@ -27,7 +27,7 @@
>
<slot name="sidebar" />
</nav>
<main class="main">
<main ref="mainEl" class="main">
<slot />
</main>
</div>
@@ -39,7 +39,7 @@
</template>
<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';
import { defineComponent, ref, computed, provide } from '@vue/composition-api';
export default defineComponent({
model: {
@@ -67,11 +67,21 @@ export default defineComponent({
type: Boolean,
default: false,
},
formWidth: {
// If the modal is used to just render a form, it needs to be a little smaller to
// allow the form to be rendered in it's correct full size
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const sidebarActive = ref(false);
const localActive = ref(false);
const mainEl = ref<Element>();
provide('main-element', mainEl);
const _active = computed({
get() {
return props.active === undefined ? localActive.value : props.active;
@@ -82,11 +92,17 @@ export default defineComponent({
},
});
return { sidebarActive, _active };
return { sidebarActive, _active, mainEl };
},
});
</script>
<style>
body {
--v-modal-max-width: 916px;
}
</style>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
@@ -94,7 +110,7 @@ export default defineComponent({
display: flex;
flex-direction: column;
width: calc(100% - 16px);
max-width: 916px;
max-width: var(--v-modal-max-width);
height: calc(100% - 16px);
max-height: 760px;
background-color: var(--background-page);
@@ -209,4 +225,8 @@ export default defineComponent({
height: calc(100% - 64px);
}
}
.form-width {
--v-modal-max-width: 856px;
}
</style>

View File

@@ -11,6 +11,7 @@
| `warning` | Shows the warning notice | `false` |
| `danger` | Shows the danger notice | `false` |
| `icon` | Custom icon name, or false if you want to hide the icon completely | `null` |
| `center` | Render notice content centered | `false` |
## Slots
| Slot | Description | Data |
@@ -21,8 +22,8 @@
n/a
## CSS Variables
| Variable | Default |
|-------------------------------|----------------------------|
| `--v-notice-color` | `var(--foreground-normal);` |
| `--v-notice-background-color` | `var(--primary-alt);` |
| `--v-notice-icon-color` | `var(--primary);` |
| Variable | Default |
|-------------------------------|-----------------------------|
| `--v-notice-color` | `var(--foreground-subdued)` |
| `--v-notice-background-color` | `var(--background-subdued)` |
| `--v-notice-icon-color` | `var(--foreground-subdued)` |

View File

@@ -1,5 +1,5 @@
<template>
<div class="v-notice" :class="className">
<div class="v-notice" :class="[className, { center }]">
<v-icon v-if="icon !== false" :name="iconName" left />
<slot />
</div>
@@ -10,6 +10,10 @@ import { defineComponent, computed } from '@vue/composition-api';
export default defineComponent({
props: {
info: {
type: Boolean,
default: false,
},
success: {
type: Boolean,
default: false,
@@ -26,6 +30,10 @@ export default defineComponent({
type: [String, Boolean],
default: null,
},
center: {
type: Boolean,
default: false,
},
},
setup(props) {
const iconName = computed(() => {
@@ -33,7 +41,9 @@ export default defineComponent({
return props.icon;
}
if (props.success) {
if (props.info) {
return 'info';
} else if (props.success) {
return 'check_circle';
} else if (props.warning) {
return 'warning';
@@ -45,7 +55,9 @@ export default defineComponent({
});
const className = computed<string | null>(() => {
if (props.success) {
if (props.info) {
return 'info';
} else if (props.success) {
return 'success';
} else if (props.warning) {
return 'warning';
@@ -63,9 +75,9 @@ export default defineComponent({
<style>
body {
--v-notice-color: var(--primary);
--v-notice-background-color: var(--primary-alt);
--v-notice-icon-color: var(--primary);
--v-notice-color: var(--foreground-subdued);
--v-notice-background-color: var(--background-subdued);
--v-notice-icon-color: var(--foreground-subdued);
}
</style>
@@ -85,6 +97,12 @@ body {
--v-icon-color: var(--v-notice-icon-color);
}
&.info {
--v-notice-icon-color: var(--primary);
--v-notice-background-color: var(--primary-alt);
--v-notice-color: var(--primary);
}
&.success {
--v-notice-icon-color: var(--success);
--v-notice-background-color: var(--success-alt);
@@ -102,5 +120,11 @@ body {
--v-notice-background-color: var(--danger-alt);
--v-notice-color: var(--danger);
}
&.center {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -123,13 +123,16 @@ body {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
background-color: var(--background-subdued);
border-radius: var(--border-radius);
content: '';
}
.label {
z-index: 1;
}
}
&:not(:disabled):hover {

View File

@@ -1,12 +1,10 @@
<template>
<transition name="fade">
<div :class="type" class="v-skeleton-loader">
<template v-if="type === 'list-item-icon'">
<div class="icon" />
<div class="text" />
</template>
</div>
</transition>
<div :class="type" class="v-skeleton-loader">
<template v-if="type === 'list-item-icon'">
<div class="icon" />
<div class="text" />
</template>
</div>
</template>
<script lang="ts">

View File

@@ -214,8 +214,9 @@ body {
left: calc(var(--_v-slider-percentage) * 1%);
width: max-content;
padding: 4px 8px;
color: var(--foreground-normal);
background-color: var(--background-normal);
color: var(--foreground-inverted);
font-weight: 600;
background-color: var(--primary);
border-radius: var(--border-radius);
transform: translateX(-50%);
opacity: 0;
@@ -227,8 +228,8 @@ body {
left: calc(50%);
width: 10px;
height: 10px;
background-color: var(--background-normal);
border-radius: var(--border-radius);
background-color: var(--primary);
border-radius: 2px;
transform: translateX(-50%) rotate(45deg);
content: '';
}

View File

@@ -123,10 +123,12 @@ export default defineComponent({
| `selection` | What items are selected. Can be used with `v-model` as well | `[]` |
| `fixed-header` | Make the header fixed | `false` |
| `loading` | Show progress indicator | `false` |
| `loadingText` | What text to show when table is loading with no items | `Loading...` |
| `loading-text` | What text to show when table is loading with no items | `Loading...` |
| `no-items-text` | What text to show when table doesn't contain any rows | `No items` |
| `server-sort` | Handle sorting on the parent level. | `false` |
| `row-height` | Height of the individual rows in px | `48` |
| `must-sort` | Requires the sort to be on a particular column | `false` |
| `disabled` | Disable edits to items in the form (drag/select) | `false` |
## Events
| Event | Description | Value |

View File

@@ -239,7 +239,7 @@ export default defineComponent({
padding: 0 12px;
font-weight: 500;
font-size: 14px;
background-color: var(--background-page);
background-color: var(--v-table-background-color);
border-bottom: 2px solid var(--border-subdued);
&.select,

View File

@@ -113,7 +113,7 @@ export default defineComponent({
line-height: var(--table-row-line-height);
white-space: nowrap;
text-overflow: ellipsis;
background-color: var(--background-page);
background-color: var(--v-table-background-color);
border-bottom: 2px solid var(--border-subdued);
&:last-child {

View File

@@ -1,5 +1,5 @@
<template>
<div class="v-table" :class="{ loading, inline }">
<div class="v-table" :class="{ loading, inline, disabled }">
<table
:summary="_headers.map((header) => header.text).join(', ')"
:style="{
@@ -25,13 +25,18 @@
</template>
</table-header>
<thead v-if="loading" class="loading-indicator" :class="{ sticky: fixedHeader }">
<th scope="colgroup" :style="{ gridColumn: loadingColSpan }">
<th scope="colgroup" :style="{ gridColumn: fullColSpan }">
<v-progress-linear indeterminate v-if="loading" />
</th>
</thead>
<tbody v-if="loading && items.length === 0">
<tr class="loading-text">
<td :style="{ gridColumn: loadingColSpan }">{{ loadingText }}</td>
<td :style="{ gridColumn: fullColSpan }">{{ loadingText }}</td>
</tr>
</tbody>
<tbody v-if="!loading && items.length === 0">
<tr class="no-items-text">
<td :style="{ gridColumn: fullColSpan }">{{ noItemsText }}</td>
</tr>
</tbody>
<draggable
@@ -39,20 +44,21 @@
v-model="_items"
tag="tbody"
handle=".drag-handle"
:disabled="_sort.by !== manualSortKey"
:disabled="disabled || _sort.by !== manualSortKey"
@change="onSortChange"
:set-data="hideDragImage"
>
<table-row
v-for="item in _items"
:key="item[itemKey]"
:headers="_headers"
:item="item"
:show-select="showSelect"
:show-manual-sort="showManualSort"
:show-select="!disabled && showSelect"
:show-manual-sort="!disabled && showManualSort"
:is-selected="getSelectedState(item)"
:subdued="loading"
:sorted-manually="_sort.by === manualSortKey"
:has-click-listener="hasRowClick"
:has-click-listener="!disabled && hasRowClick"
:height="rowHeight"
@click="hasRowClick ? $emit('click:row', item) : null"
@item-selected="onItemSelected"
@@ -79,6 +85,7 @@ import TableRow from './table-row/';
import { sortBy, clone, forEach, pick } from 'lodash';
import { i18n } from '@/lang/';
import draggable from 'vuedraggable';
import hideDragImage from '@/utils/hide-drag-image';
const HeaderDefaults: Header = {
text: '',
@@ -151,6 +158,10 @@ export default defineComponent({
type: String,
default: i18n.t('loading'),
},
noItemsText: {
type: String,
default: i18n.t('no_items'),
},
serverSort: {
type: Boolean,
default: false,
@@ -167,6 +178,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit, listeners, slots }) {
const _headers = computed({
@@ -223,7 +238,7 @@ export default defineComponent({
const hasItemAppendSlot = computed(() => slots['item-append'] !== undefined);
const loadingColSpan = computed<string>(() => {
const fullColSpan = computed<string>(() => {
let length = _headers.value.length + 1; // +1 account for spacer
if (props.showSelect) length++;
if (props.showManualSort) length++;
@@ -287,12 +302,15 @@ export default defineComponent({
someItemsSelected,
onSortChange,
hasRowClick,
loadingColSpan,
fullColSpan,
columnStyle,
hasItemAppendSlot,
hideDragImage,
};
function onItemSelected(event: ItemSelectEvent) {
if (props.disabled) return;
emit('item-selected', event);
let selection = clone(props.selection) as any[];
@@ -324,6 +342,8 @@ export default defineComponent({
}
function onToggleSelectAll(value: boolean) {
if (props.disabled) return;
if (value === true) {
if (props.selectionUseKeys) {
emit(
@@ -348,6 +368,8 @@ export default defineComponent({
}
function onSortChange(event: VueDraggableChangeEvent) {
if (props.disabled) return;
if (event.moved) {
emit('manual-sort', {
item: event.moved.element,
@@ -364,6 +386,8 @@ export default defineComponent({
body {
--v-table-height: auto;
--v-table-sticky-offset-top: 0;
--v-table-color: var(--foreground-normal);
--v-table-background-color: var(--background-page);
}
</style>
@@ -399,7 +423,7 @@ body {
td,
th {
color: var(--foreground-normal);
color: var(--v-table-color);
&.align-left {
text-align: left;
@@ -452,20 +476,30 @@ body {
z-index: 2;
}
}
}
.loading-text {
text-align: center;
.loading-text,
.no-items-text {
text-align: center;
td {
padding: 16px;
color: var(--foreground-subdued);
}
td {
padding: 16px;
color: var(--foreground-subdued);
}
}
&.inline {
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
table ::v-deep .table-row:last-of-type .cell {
border-bottom: none;
}
}
}
.inline {
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
.disabled {
--v-table-color: var(--foreground-subdued);
--v-table-background-color: var(--background-subdued);
}
</style>

View File

@@ -128,23 +128,22 @@ body {
}
}
&:hover {
&.full-width {
width: 100%;
}
&:hover:not(.disabled) {
border-color: var(--border-normal-alt);
}
&:focus,
&:focus-within {
&:focus:not(.disabled),
&:focus-within:not(.disabled) {
border-color: var(--primary);
}
&.full-width {
width: 100%;
}
&.disabled {
color: var(--foreground-subdued);
background-color: var(--background-subdued);
cursor: not-allowed;
}
textarea {

View File

@@ -7,7 +7,7 @@ type Items = Readonly<
Ref<
| readonly {
text: string;
value: string;
value: string | boolean | number;
}[]
| null
>

View File

@@ -107,7 +107,7 @@ export function useItems(collection: Ref<string>, query: Query) {
}
});
return { itemCount, totalCount, items, totalPages, loading, error, changeManualSort };
return { itemCount, totalCount, items, totalPages, loading, error, changeManualSort, getItems };
async function getItems() {
loading.value = true;

View File

@@ -1,5 +1,5 @@
<template>
<span>{{ displayValue }}</span>
<span class="datetime">{{ displayValue }}</span>
</template>
<script lang="ts">
@@ -7,6 +7,7 @@ import { defineComponent, ref, watch } from '@vue/composition-api';
import formatLocalized from '@/utils/localized-format';
import i18n from '@/lang';
import parse from 'date-fns/parse';
import parseISO from 'date-fns/parseISO';
export default defineComponent({
props: {
@@ -30,15 +31,19 @@ export default defineComponent({
return;
}
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
let date: Date;
if (newValue.includes('T')) {
date = parseISO(props.value);
} else {
date = parse(props.value, 'yyyy-MM-dd HH:mm:ss', new Date());
}
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
displayValue.value = await formatLocalized(
parse(props.value, 'yyyy-MM-dd HH:mm:ss', new Date()),
format
);
displayValue.value = await formatLocalized(date, format);
}
);
@@ -46,3 +51,11 @@ export default defineComponent({
},
});
</script>
<style lang="scss" scoped>
.datetime {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@@ -40,6 +40,7 @@ export default defineComponent({
<style lang="scss" scoped>
.dot {
display: inline-block;
flex-shrink: 0;
width: 12px;
height: 12px;
margin: 0 4px;

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;

View File

@@ -130,9 +130,22 @@
"december": "December"
},
"drag_mode": "Drag Mode",
"cancel_crop": "Cancel Crop",
"many_to_many": "Many to Many (M2M)",
"one_to_many": "One to Many (O2M)",
"many_to_one": "Many to One (M2O)",
"original": "Original",
"file_details": "File Details",
"dimensions": "Dimensions",
"size": "Size",
"created": "Created",
"modified": "Modified",
"checksum": "Checksum",
"owner": "Owner",
"folder": "Folder",
"set_to_now": "Set to Now",
@@ -170,6 +183,12 @@
"select_item": "Select Item",
"no_items": "No items",
"disabled": "Disabled",
"interface_not_found": "Interface \"{interface}\" not found",
"item_count": "No Items | One Item | {count} Items",
"no_items_copy": "It looks like you dont have any items in this collection. You can click the button below to add an item.",
"all_items": "All Items",
@@ -185,7 +204,9 @@
"relationship_not_setup": "The relationship hasn't been configured correctly",
"display_template_not_setup": "The display template is misconfigured",
"choose_status": "Choose Status...",
"active": "Active",
"choose_status": "Choose a status...",
"users": "Users",
"files": "Files",
@@ -265,7 +286,7 @@
"back": "Back",
"editing_image": "Editing Image...",
"editing_image": "Editing Image",
"square": "Square",
"free": "Free",
"flip_horizontal": "Flip Horizontal",
@@ -507,6 +528,9 @@
"adding_in": "Adding New Item in {collection}",
"editing_in": "Editing Item in {collection}",
"editing_in_batch": "Batch Editing {count} Items",
"no_options_available": "No options available",
"settings_data_model": "Data Model",
"settings_collections_fields": "Collections & Fields",
@@ -709,6 +733,7 @@
"extensions_missing": "No Extensions Found",
"extensions_missing_copy": "Make sure you have the system extensions installed.",
"extra_spacing": "Extra Spacing",
"fallback_icon": "Fallback Icon",
"fetching_data": "Fetching Data",
"field": "Field | Fields",
"field_already_exists": "Field Already Exists: {field}",

View File

@@ -44,8 +44,8 @@
</div>
<div class="layout-option">
<div class="option-label">{{ $t('layouts.cards.fallback_icon') }}</div>
<v-input v-model="icon" />
<div class="option-label">{{ $t('fallback_icon') }}</div>
<interface-icon v-model="icon" />
</div>
</v-detail>
</portal>
@@ -70,12 +70,13 @@
<div class="grid" :class="{ 'single-row': isSingleRow }">
<template v-if="loading">
<card v-for="n in limit" :key="`loader-${n}`" loading />
<card v-for="n in limit" :key="`loader-${n}`" item-key="loading" loading />
</template>
<card
v-else
v-for="item in items"
:item-key="primaryKeyField.field"
:key="item[primaryKeyField.field]"
:crop="imageFit === 'crop'"
:icon="icon"
@@ -123,7 +124,12 @@
</div>
</template>
<v-info v-else-if="itemCount === 0" :title="$t('no_results')" icon="search">
<v-info
v-else-if="itemCount === 0 && activeFilterCount > 0"
:title="$t('no_results')"
icon="search"
center
>
{{ $t('no_results_copy') }}
<template #append>
@@ -131,7 +137,7 @@
</template>
</v-info>
<v-info v-else :title="$tc('item_count', 0)" :icon="info.icon">
<v-info v-else :title="$tc('item_count', 0)" :icon="info.icon" center>
{{ $t('no_items_copy') }}
<template #append>
@@ -242,7 +248,7 @@ export default defineComponent({
const { size, icon, imageSource, title, subtitle, imageFit } = useViewOptions();
const { sort, limit, page, fields } = useViewQuery();
const { items, loading, error, totalPages, itemCount } = useItems(collection, {
const { items, loading, error, totalPages, itemCount, getItems } = useItems(collection, {
sort,
limit,
page,
@@ -276,6 +282,10 @@ export default defineComponent({
return cardsWidth <= width.value;
});
const activeFilterCount = computed(() => {
return _filters.value.filter((filter) => !filter.locked);
});
return {
_selection,
items,
@@ -306,8 +316,14 @@ export default defineComponent({
isSingleRow,
width,
layoutElement,
activeFilterCount,
refresh,
};
function refresh() {
getItems();
}
function toPage(newPage: number) {
page.value = newPage;
mainElement.value?.scrollTo({
@@ -438,10 +454,6 @@ export default defineComponent({
}
}
.v-info {
margin: 20vh 0;
}
.footer {
display: flex;
align-items: center;

View File

@@ -1,6 +1,6 @@
<template>
<div class="card" :class="{ loading, readonly }" @click="handleClick">
<div class="header" :class="{ selected: value.includes(item) }">
<div class="header" :class="{ selected: item && value.includes(item[itemKey]) }">
<div class="selection-indicator" :class="{ 'select-mode': selectMode }">
<v-icon class="selector" :name="selectionIcon" @click.stop="toggleSelection" />
</div>
@@ -74,7 +74,7 @@ export default defineComponent({
default: null,
},
value: {
type: Array as PropType<Record<string, any>[]>,
type: Array as PropType<(string | number)[]>,
default: () => [],
},
selectMode: {
@@ -89,6 +89,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
itemKey: {
type: String,
required: true,
},
},
setup(props, { emit }) {
const type = computed(() => {
@@ -123,20 +127,26 @@ export default defineComponent({
return props.file.data.full_url;
});
const selectionIcon = computed(() =>
props.value.includes(props.item) ? 'check_circle' : 'radio_button_unchecked'
);
const selectionIcon = computed(() => {
if (!props.item) return 'radio_button_unchecked';
return props.value.includes(props.item[props.itemKey])
? 'check_circle'
: 'radio_button_unchecked';
});
return { imageSource, svgSource, type, selectionIcon, toggleSelection, handleClick };
function toggleSelection() {
if (props.value.includes(props.item)) {
if (!props.item) return null;
if (props.value.includes(props.item[props.itemKey])) {
emit(
'input',
props.value.filter((item) => item !== props.item)
props.value.filter((key) => key !== props.item[props.itemKey])
);
} else {
emit('input', [...props.value, props.item]);
emit('input', [...props.value, props.item[props.itemKey]]);
}
}
@@ -234,20 +244,6 @@ export default defineComponent({
opacity: 1;
}
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
-180deg,
rgba(38, 50, 56, 0.2) 10%,
rgba(38, 50, 56, 0)
);
content: '';
}
}
&.selected {
@@ -275,24 +271,17 @@ export default defineComponent({
.title,
.subtitle {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 20px;
overflow: hidden;
line-height: 1.3em;
white-space: nowrap;
text-overflow: ellipsis;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 12px;
background: linear-gradient(
90deg,
rgba(var(--background-page-rgb), 0) 0%,
rgba(var(--background-page-rgb), 1) 100%
);
content: '';
> .render-template ::v-deep > *:not(:last-child) {
margin-right: 4px;
}
}

View File

@@ -24,7 +24,7 @@
<div class="layout-option">
<div class="option-label">{{ $t('layouts.tabular.fields') }}</div>
<draggable v-model="activeFields" handle=".drag-handle">
<draggable v-model="activeFields" handle=".drag-handle" :set-data="hideDragImage">
<v-checkbox
v-for="field in activeFields"
v-model="fields"
@@ -86,11 +86,7 @@
@manual-sort="changeManualSort"
>
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
<span :key="header.value" v-if="!header.field.display">
{{ item[header.value] }}
</span>
<render-display
v-else
:key="header.value"
:value="item[header.value]"
:display="header.field.display"
@@ -130,9 +126,10 @@
</v-table>
<v-info
v-else-if="itemCount === 0 && _filters.length > 0"
v-else-if="itemCount === 0 && activeFilterCount > 0"
:title="$t('no_results')"
icon="search"
center
>
{{ $t('no_results_copy') }}
@@ -141,7 +138,7 @@
</template>
</v-info>
<v-info v-else :title="$tc('item_count', 0)" :icon="info.icon">
<v-info v-else :title="$tc('item_count', 0)" :icon="info.icon" center>
{{ $t('no_items_copy') }}
<template #append>
@@ -175,6 +172,7 @@ import { render } from 'micromustache';
import { Filter } from '@/stores/collection-presets/types';
import i18n from '@/lang';
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
import hideDragImage from '@/utils/hide-drag-image';
type ViewOptions = {
widths?: {
@@ -252,17 +250,22 @@ export default defineComponent({
const { sort, limit, page, fields, fieldsWithRelational } = useItemOptions();
const { items, loading, error, totalPages, itemCount, changeManualSort } = useItems(
collection,
{
sort,
limit,
page,
fields: fieldsWithRelational,
filters: _filters,
searchQuery,
}
);
const {
items,
loading,
error,
totalPages,
itemCount,
changeManualSort,
getItems,
} = useItems(collection, {
sort,
limit,
page,
fields: fieldsWithRelational,
filters: _filters,
searchQuery,
});
const {
tableSort,
@@ -291,6 +294,10 @@ export default defineComponent({
});
});
const activeFilterCount = computed(() => {
return _filters.value.filter((filter) => !filter.locked);
});
return {
_selection,
table,
@@ -319,8 +326,15 @@ export default defineComponent({
showingCount,
sortField,
changeManualSort,
hideDragImage,
activeFilterCount,
refresh,
};
function refresh() {
getItems();
}
function clearFilters() {
_filters.value = [];
_searchQuery.value = null;
@@ -580,10 +594,6 @@ export default defineComponent({
}
}
.v-info {
margin: 20vh 0;
}
.v-checkbox {
width: 100%;

View File

@@ -89,6 +89,7 @@
v-if="bookmark && bookmarkExists === false"
:title="$t('bookmark_doesnt_exist')"
icon="bookmark"
center
>
{{ $t('bookmark_doesnt_exist_copy') }}
@@ -194,7 +195,7 @@ export default defineComponent({
const projectsStore = useProjectsStore();
const { selection } = useSelection();
const { info: currentCollection, primaryKeyField } = useCollection(collection);
const { info: currentCollection } = useCollection(collection);
const { addNewLink, batchLink, collectionsLink, currentCollectionLink } = useLinks();
const { breadcrumb } = useBreadcrumb();
const {
@@ -254,7 +255,7 @@ export default defineComponent({
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: props.collection,
name: currentCollection.value?.name,
to: `/${projectsStore.state.currentProjectKey}/collections/${props.collection}`,
},
]);
@@ -287,20 +288,22 @@ export default defineComponent({
confirmDelete.value = false;
const batchPrimaryKeys = selection.value
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((item) => item[primaryKeyField.value!.field])
.join();
const batchPrimaryKeys = selection.value;
await api.delete(
`/${currentProjectKey}/items/${props.collection}/${batchPrimaryKeys}`
);
try {
await api.delete(
`/${currentProjectKey}/items/${props.collection}/${batchPrimaryKeys}`
);
await layout.value?.refresh();
await layout.value?.refresh?.();
selection.value = [];
deleting.value = false;
confirmDelete.value = false;
selection.value = [];
confirmDelete.value = false;
} catch (err) {
console.error(err);
} finally {
deleting.value = false;
}
}
}
@@ -397,10 +400,6 @@ export default defineComponent({
--layout-offset-top: 64px;
}
.v-info {
margin: 20vh 0;
}
.bookmark-add .toggle,
.bookmark-edit .toggle {
margin-left: 8px;

View File

@@ -1,14 +1,10 @@
<template>
<collections-not-found v-if="error && error.code === 404" />
<private-view
v-else
:title="
isNew
? $t('adding_in', { collection: collectionInfo.name })
: $t('editing_in', { collection: collectionInfo.name })
"
>
<template #title v-if="isNew === false && collectionInfo.display_template">
<private-view v-else :title="title">
<template
#title
v-if="isNew === false && isBatch === false && collectionInfo.display_template"
>
<v-skeleton-loader class="title-loader" type="text" v-if="loading" />
<h1 class="type-title" v-else>
<render-template
@@ -151,6 +147,7 @@
v-if="isBatch === false && isNew === false"
:collection="collection"
:primary-key="primaryKey"
ref="revisionsDrawerDetail"
/>
<comments-drawer-detail
v-if="isBatch === false && isNew === false"
@@ -172,6 +169,7 @@ import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-d
import CommentsDrawerDetail from '@/views/private/components/comments-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import i18n from '@/lang';
type Values = {
[field: string]: any;
@@ -202,6 +200,8 @@ export default defineComponent({
const { collection, primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const revisionsDrawerDetail = ref<Vue>(null);
const { info: collectionInfo, softDeleteStatus, primaryKeyField } = useCollection(
collection
);
@@ -237,6 +237,17 @@ export default defineComponent({
};
});
const title = computed(() => {
if (isBatch.value) {
const itemCount = props.primaryKey.split(',').length;
return i18n.t('editing_in_batch', { count: itemCount });
}
return isNew.value
? i18n.t('adding_in', { collection: collectionInfo.value?.name })
: i18n.t('editing_in', { collection: collectionInfo.value?.name });
});
return {
item,
loading,
@@ -260,6 +271,8 @@ export default defineComponent({
softDeleteStatus,
templateValues,
breadcrumb,
title,
revisionsDrawerDetail,
};
function useBreadcrumb() {
@@ -281,6 +294,8 @@ export default defineComponent({
async function saveAndStay() {
const savedItem: Record<string, any> = await save();
revisionsDrawerDetail.value?.$data?.refresh?.();
if (props.primaryKey === '+') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newPrimaryKey = savedItem[primaryKeyField.value!.field];

View File

@@ -21,7 +21,7 @@
</template>
</v-table>
<v-info icon="box" :title="$t('no_collections')" v-else>
<v-info icon="box" :title="$t('no_collections')" v-else center>
<template v-if="isAdmin">
{{ $t('no_collections_copy_admin') }}
</template>
@@ -106,8 +106,4 @@ export default defineComponent({
padding: var(--content-padding);
padding-top: 0;
}
.v-info {
margin: 20vh 0;
}
</style>

View File

@@ -179,7 +179,7 @@ export default defineComponent({
confirmDelete.value = false;
const batchPrimaryKeys = selection.value.map((item) => item.id).join();
const batchPrimaryKeys = selection.value;
await api.delete(`/${currentProjectKey}/files/${batchPrimaryKeys}`);
@@ -199,7 +199,7 @@ export default defineComponent({
const batchLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
const batchPrimaryKeys = selection.value.map((item) => item.id).join();
const batchPrimaryKeys = selection.value;
return `/${currentProjectKey}/files/${batchPrimaryKeys}`;
});

View File

@@ -0,0 +1,116 @@
<template>
<drawer-detail icon="info_outline" :title="$t('file_details')">
<dl>
<div v-if="type">
<dt>{{ $t('type') }}</dt>
<dd>{{ readableMimeType(type) || type }}</dd>
</div>
<div v-if="width && height">
<dt>{{ $t('dimensions') }}</dt>
<dd>{{ $n(width) }} × {{ $n(height) }}</dd>
</div>
<div v-if="filesize">
<dt>{{ $t('size') }}</dt>
<dd>{{ size }}</dd>
</div>
<div v-if="creationDate">
<dt>{{ $t('created') }}</dt>
<dd>{{ creationDate }}</dd>
</div>
<div v-if="checksum" class="checksum">
<dt>{{ $t('checksum') }}</dt>
<dd>{{ checksum }}</dd>
</div>
</dl>
</drawer-detail>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import readableMimeType from '@/utils/readable-mime-type';
import prettyBytes from 'pretty-bytes';
import i18n from '@/lang';
import localizedFormat from '@/utils/localized-format';
export default defineComponent({
inheritAttrs: false,
props: {
type: {
type: String,
default: null,
},
width: {
type: Number,
default: null,
},
height: {
type: Number,
default: null,
},
filesize: {
type: Number,
default: null,
},
uploaded_on: {
type: String,
default: null,
},
checksum: {
type: String,
default: null,
},
},
setup(props) {
const size = computed(() => {
if (!props.filesize) return null;
return prettyBytes(props.filesize, { locale: i18n.locale.split('-')[0] });
});
const creationDate = ref<string>(null);
localizedFormat(new Date(props.uploaded_on), String(i18n.t('date-fns_datetime'))).then(
(result) => {
creationDate.value = result;
}
);
return { readableMimeType, size, creationDate };
},
});
</script>
<style lang="scss" scoped>
dl > div {
display: flex;
margin-bottom: 12px;
}
dt,
dd {
display: inline-block;
}
dt {
margin-right: 8px;
font-weight: 600;
}
dd {
flex-grow: 1;
overflow: hidden;
color: var(--foreground-subdued);
white-space: nowrap;
text-overflow: ellipsis;
}
.checksum {
dd {
font-family: var(--family-monospace);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<private-view :title="loading ? $t('loading') : item.title">
<private-view :title="loading || !item ? $t('loading') : item.title">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon secondary exact :to="breadcrumb[0].to">
<v-icon name="arrow_back" />
@@ -83,7 +83,7 @@
@click="previewActive = true"
/>
<file-lightbox :id="item.id" v-model="previewActive" />
<file-lightbox v-if="item" :id="item.id" v-model="previewActive" />
<image-editor
v-if="item && item.type.startsWith('image')"
@@ -93,9 +93,9 @@
/>
<v-form
:fields="formFields"
:loading="loading"
:initial-values="item"
collection="directus_files"
:batch-mode="isBatch"
:primary-key="primaryKey"
v-model="edits"
@@ -103,10 +103,17 @@
</div>
<template #drawer>
<file-info-drawer-detail v-if="isNew === false" v-bind="item" />
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
collection="directus_files"
:primary-key="primaryKey"
ref="revisionsDrawerDetail"
/>
<comments-drawer-detail
v-if="isBatch === false && isNew === false"
collection="directus_files"
:primary-key="primaryKey"
/>
</template>
</private-view>
@@ -119,12 +126,16 @@ import FilesNavigation from '../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsDrawerDetail from '@/views/private/components/comments-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import FilePreview from '@/views/private/components/file-preview';
import ImageEditor from '@/views/private/components/image-editor';
import { nanoid } from 'nanoid';
import FileLightbox from '@/views/private/components/file-lightbox';
import useFieldsStore from '@/stores/fields';
import { Field } from '@/stores/fields/types';
import FileInfoDrawerDetail from './components/file-info-drawer-detail.vue';
type Values = {
[field: string]: any;
@@ -135,10 +146,12 @@ export default defineComponent({
components: {
FilesNavigation,
RevisionsDrawerDetail,
CommentsDrawerDetail,
SaveOptions,
FilePreview,
ImageEditor,
FileLightbox,
FileInfoDrawerDetail,
},
props: {
primaryKey: {
@@ -151,6 +164,9 @@ export default defineComponent({
const { currentProjectKey } = toRefs(projectsStore.state);
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const fieldsStore = useFieldsStore();
const revisionsDrawerDetail = ref<Vue>(null);
const {
isNew,
@@ -167,15 +183,20 @@ export default defineComponent({
} = useItem(ref('directus_files'), primaryKey);
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
const confirmDelete = ref(false);
const cacheBuster = ref(nanoid());
const editActive = ref(false);
const previewActive = ref(false);
// These are the fields that will be prevented from showing up in the form
const fieldsBlacklist: string[] = ['type', 'width', 'height', 'filesize', 'checksum'];
const formFields = computed(() => {
return fieldsStore
.getFieldsForCollection('directus_files')
.filter((field: Field) => fieldsBlacklist.includes(field.field) === false);
});
return {
item,
loading,
@@ -197,6 +218,8 @@ export default defineComponent({
cacheBuster,
editActive,
previewActive,
revisionsDrawerDetail,
formFields,
};
function changeCacheBuster() {
@@ -222,6 +245,8 @@ export default defineComponent({
async function saveAndStay() {
const savedItem: Record<string, any> = await save();
revisionsDrawerDetail.value?.$data?.refresh?.();
if (props.primaryKey === '+') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newPrimaryKey = savedItem.id;

View File

@@ -2,10 +2,10 @@ import { RouteConfig } from 'vue-router';
import { replaceRoutes } from '@/router';
import modules from './index';
const moduleRoutes: RouteConfig[] = modules
const moduleRoutes = modules
.map((module) => module.routes)
.filter((r) => r)
.flat();
.flat() as RouteConfig[];
replaceRoutes((routes) => insertBeforeProjectWildcard(routes, moduleRoutes));

View File

@@ -26,6 +26,7 @@
icon="box"
:title="$t('no_collections')"
v-if="items.length === 0"
center
>
{{ $t('no_collections_copy_admin') }}
@@ -57,7 +58,7 @@
/>
</template>
<template #item.collection="{ item }">
<template #item.name="{ item }">
<span
class="collection"
:class="{
@@ -249,10 +250,6 @@ export default defineComponent({
display: contents;
}
.v-info {
margin: 20vh 0;
}
.header-icon {
--v-button-color-disabled: var(--warning);
--v-button-background-color-disabled: var(--warning-25);

View File

@@ -17,6 +17,10 @@
:edits="value.options"
@input="emitValue('options', $event)"
/>
<v-notice v-else>
{{ $t('no_options_available') }}
</v-notice>
</div>
</template>

View File

@@ -19,6 +19,10 @@
:edits="value.options"
@input="emitValue('options', $event)"
/>
<v-notice v-else>
{{ $t('no_options_available') }}
</v-notice>
</div>
</template>

View File

@@ -216,6 +216,10 @@ export default defineComponent({
if (field.value.id === null) {
await fieldsStore.createField(props.collection, field.value);
} else {
if (field.value.hasOwnProperty('name')) {
delete field.value.name;
}
await fieldsStore.updateField(
props.existingField.collection,
props.existingField.field,

View File

@@ -6,6 +6,7 @@
handle=".drag-handle"
group="fields"
@change="($event) => handleChange($event, 'visible')"
:set-data="hideDragImage"
>
<template #header>
<div class="group-name">Visible Fields</div>
@@ -33,6 +34,7 @@
:value="sortedHiddenFields"
handle=".drag-handle"
group="fields"
:set-data="hideDragImage"
@change="($event) => handleChange($event, 'hidden')"
>
<template #header>
@@ -74,6 +76,7 @@ import useFieldsStore from '@/stores/fields/';
import FieldSelect from '../field-select/';
import FieldSetup from '../field-setup/';
import { sortBy } from 'lodash';
import hideDragImage from '@/utils/hide-drag-image';
type DraggableEvent = {
moved?: {
@@ -125,6 +128,7 @@ export default defineComponent({
editingField,
openFieldSetup,
closeFieldSetup,
hideDragImage,
};
function handleChange(event: DraggableEvent, location: 'visible' | 'hidden') {

View File

@@ -43,6 +43,7 @@
<div class="presets-browse">
<v-info
center
type="warning"
v-if="presets.length === 0"
:title="$t('no_presets')"
@@ -314,8 +315,4 @@ export default defineComponent({
.default {
color: var(--foreground-subdued);
}
.v-info {
margin: 20vh 0;
}
</style>

View File

@@ -466,7 +466,7 @@ export default defineComponent({
field: 'divider',
name: i18n.t('divider'),
interface: 'divider',
width: 'full',
width: 'fill',
options: {
title: i18n.t('layout_preview'),
},

View File

@@ -84,11 +84,29 @@ export default defineComponent({
const selection = ref<Item[]>([]);
const { viewOptions, viewQuery } = useCollectionPreset(ref('directus_webhooks'));
const { viewType, viewOptions, viewQuery } = useCollectionPreset(ref('directus_webhooks'));
const { addNewLink, batchLink } = useLinks();
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const { breadcrumb } = useBreadcrumb();
if (viewType.value === null) {
viewType.value = 'tabular';
}
if (viewOptions.value === null && viewType.value === 'tabular') {
viewOptions.value = {
widths: {
status: 50,
},
};
}
if (viewQuery.value === null && viewType.value === 'tabular') {
viewQuery.value = {
fields: ['status', 'http_action', 'url'],
};
}
return {
addNewLink,
batchLink,

View File

@@ -177,7 +177,7 @@ export default defineComponent({
confirmDelete.value = false;
const batchPrimaryKeys = selection.value.map((item) => item.id).join();
const batchPrimaryKeys = selection.value;
await api.delete(`/${currentProjectKey}/users/${batchPrimaryKeys}`);
@@ -197,7 +197,7 @@ export default defineComponent({
const batchLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
const batchPrimaryKeys = selection.value.map((item) => item.id).join();
const batchPrimaryKeys = selection.value;
return `/${currentProjectKey}/users/${batchPrimaryKeys}`;
});

View File

@@ -76,6 +76,12 @@
v-if="isBatch === false && isNew === false"
collection="directus_users"
:primary-key="primaryKey"
ref="revisionsDrawerDetail"
/>
<comments-drawer-detail
v-if="isBatch === false && isNew === false"
collection="directus_users"
:primary-key="primaryKey"
/>
</template>
</private-view>
@@ -88,6 +94,7 @@ import UsersNavigation from '../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsDrawerDetail from '@/views/private/components/comments-drawer-detail';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
@@ -97,7 +104,7 @@ type Values = {
export default defineComponent({
name: 'users-detail',
components: { UsersNavigation, RevisionsDrawerDetail, SaveOptions },
components: { UsersNavigation, RevisionsDrawerDetail, SaveOptions, CommentsDrawerDetail },
props: {
primaryKey: {
type: String,
@@ -110,6 +117,8 @@ export default defineComponent({
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const revisionsDrawerDetail = ref<Vue>(null);
const {
isNew,
edits,
@@ -145,6 +154,7 @@ export default defineComponent({
saveAndAddNew,
saveAsCopyAndNavigate,
isBatch,
revisionsDrawerDetail,
};
function useBreadcrumb() {
@@ -166,6 +176,8 @@ export default defineComponent({
async function saveAndStay() {
const savedItem: Record<string, any> = await save();
revisionsDrawerDetail.value?.$data?.refresh?.();
if (props.primaryKey === '+') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newPrimaryKey = savedItem.id;

View File

@@ -3,6 +3,7 @@ import { createStore } from 'pinia';
export const useAppStore = createStore({
id: 'appStore',
state: () => ({
drawerOpen: false,
hydrated: false,
hydrating: false,
error: null,

View File

@@ -55,6 +55,14 @@ textarea,
[contenteditable],
.selectable {
user-select: text;
/* stylelint-disable no-descending-specificity */
* {
user-select: text;
}
/* stylelint-enable no-descending-specificity */
}
:invalid {

View File

@@ -11,8 +11,8 @@
display: none;
max-width: 260px;
padding: 4px 8px;
color: var(--foreground-inverted);
background-color: var(--background-inverted);
color: var(--tooltip-foreground-color);
background-color: var(--tooltip-background-color);
border-radius: 4px;
transition: opacity 200ms;

View File

@@ -0,0 +1,4 @@
export default function hideDragImage(dataTransfer: DataTransfer) {
const emptyImg = new Image();
dataTransfer.setDragImage(emptyImg, 0, 0);
}

View File

@@ -0,0 +1,4 @@
import hideDragImage from './hide-drag-image';
export { hideDragImage };
export default hideDragImage;

View File

@@ -1,4 +0,0 @@
import parseChoices from './parse-choices';
export { parseChoices };
export default parseChoices;

View File

@@ -1,42 +0,0 @@
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' });
});
});

View File

@@ -1,21 +0,0 @@
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],
};
});
}

View File

@@ -0,0 +1,4 @@
import readableMimeType from './readable-mime-type';
export { readableMimeType };
export default readableMimeType;

View File

@@ -0,0 +1,5 @@
import types from './types.json';
export default function readableMimeType(type: string) {
return (types as any)[type] || null;
}

View File

@@ -0,0 +1,693 @@
{
"application/vnd.hzn-3d-crossword": "3D Crossword Plugin",
"video/3gpp": "3GP",
"video/3gpp2": "3GP2",
"application/vnd.mseq": "3GPP MSEQ File",
"application/vnd.3m.post-it-notes": "3M Post It Notes",
"application/vnd.3gpp.pic-bw-large": "3rd Generation Partnership Project - Pic Large",
"application/vnd.3gpp.pic-bw-small": "3rd Generation Partnership Project - Pic Small",
"application/vnd.3gpp.pic-bw-var": "3rd Generation Partnership Project - Pic Var",
"application/vnd.3gpp2.tcap": "3rd Generation Partnership Project - Transaction Capabilities Application Part",
"application/x-7z-compressed": "7-Zip",
"application/x-abiword": "AbiWord",
"application/x-ace-compressed": "Ace Archive",
"application/vnd.americandynamics.acc": "Active Content Compression",
"application/vnd.acucobol": "ACU Cobol",
"application/vnd.acucorp": "ACU Cobol",
"audio/adpcm": "Adaptive differential pulse-code modulation",
"application/x-authorware-bin": "Adobe (Macropedia) Authorware - Binary File",
"application/x-authorware-map": "Adobe (Macropedia) Authorware - Map",
"application/x-authorware-seg": "Adobe (Macropedia) Authorware - Segment File",
"application/vnd.adobe.air-application-installer-package+zip": "Adobe AIR Application",
"application/x-shockwave-flash": "Adobe Flash",
"application/vnd.adobe.fxp": "Adobe Flex Project",
"application/pdf": "Adobe Portable Document Format",
"application/vnd.cups-ppd": "Adobe PostScript Printer Description File Format",
"application/x-director": "Adobe Shockwave Player",
"application/vnd.adobe.xdp+xml": "Adobe XML Data Package",
"application/vnd.adobe.xfdf": "Adobe XML Forms Data Format",
"audio/x-aac": "Advanced Audio Coding (AAC)",
"application/vnd.ahead.space": "Ahead AIR Application",
"application/vnd.airzip.filesecure.azf": "AirZip FileSECURE",
"application/vnd.airzip.filesecure.azs": "AirZip FileSECURE",
"application/vnd.amazon.ebook": "Amazon Kindle eBook format",
"application/vnd.amiga.ami": "AmigaDE",
"application/andrew-inset": "Andrew Toolkit",
"application/vnd.android.package-archive": "Android Package Archive",
"application/vnd.anser-web-certificate-issue-initiation": "ANSER-WEB Terminal Client - Certificate Issue",
"application/vnd.anser-web-funds-transfer-initiation": "ANSER-WEB Terminal Client - Web Funds Transfer",
"application/vnd.antix.game-component": "Antix Game Player",
"application/x-apple-diskimage": "Apple Disk Image",
"application/vnd.apple.installer+xml": "Apple Installer Package",
"application/applixware": "Applixware",
"application/vnd.hhe.lesson-player": "Archipelago Lesson Player",
"application/vnd.aristanetworks.swi": "Arista Networks Software Image",
"text/x-asm": "Assembler Source File",
"application/atomcat+xml": "Atom Publishing Protocol",
"application/atomsvc+xml": "Atom Publishing Protocol Service Document",
"application/atom+xml": "Atom Syndication Format",
"application/pkix-attr-cert": "Attribute Certificate",
"audio/x-aiff": "Audio Interchange File Format",
"video/x-msvideo": "Audio Video Interleave (AVI)",
"application/vnd.audiograph": "Audiograph",
"image/vnd.dxf": "AutoCAD DXF",
"model/vnd.dwf": "Autodesk Design Web Format (DWF)",
"text/plain-bas": "BAS Partitur Format",
"application/x-bcpio": "Binary CPIO Archive",
"application/octet-stream": "Binary Data",
"image/bmp": "Bitmap Image File",
"application/x-bittorrent": "BitTorrent",
"application/vnd.rim.cod": "Blackberry COD File",
"application/vnd.blueice.multipass": "Blueice Research Multipass",
"application/vnd.bmi": "BMI Drawing Data Interchange",
"application/x-sh": "Bourne Shell Script",
"image/prs.btif": "BTIF",
"application/vnd.businessobjects": "BusinessObjects",
"application/x-bzip": "Bzip Archive",
"application/x-bzip2": "Bzip2 Archive",
"application/x-csh": "C Shell Script",
"text/x-c": "C Source File",
"application/vnd.chemdraw+xml": "CambridgeSoft Chem Draw",
"text/css": "Cascading Style Sheets (CSS)",
"chemical/x-cdx": "ChemDraw eXchange file",
"chemical/x-cml": "Chemical Markup Language",
"chemical/x-csml": "Chemical Style Markup Language",
"application/vnd.contact.cmsg": "CIM Database",
"application/vnd.claymore": "Claymore Data Files",
"application/vnd.clonk.c4group": "Clonk Game",
"image/vnd.dvb.subtitle": "Close Captioning - Subtitle",
"application/cdmi-capability": "Cloud Data Management Interface (CDMI) - Capability",
"application/cdmi-container": "Cloud Data Management Interface (CDMI) - Contaimer",
"application/cdmi-domain": "Cloud Data Management Interface (CDMI) - Domain",
"application/cdmi-object": "Cloud Data Management Interface (CDMI) - Object",
"application/cdmi-queue": "Cloud Data Management Interface (CDMI) - Queue",
"application/vnd.cluetrust.cartomobile-config": "ClueTrust CartoMobile - Config",
"application/vnd.cluetrust.cartomobile-config-pkg": "ClueTrust CartoMobile - Config Package",
"image/x-cmu-raster": "CMU Image",
"model/vnd.collada+xml": "COLLADA",
"text/csv": "Comma-Seperated Values",
"application/mac-compactpro": "Compact Pro",
"application/vnd.wap.wmlc": "Compiled Wireless Markup Language (WMLC)",
"image/cgm": "Computer Graphics Metafile",
"x-conference/x-cooltalk": "CoolTalk",
"image/x-cmx": "Corel Metafile Exchange (CMX)",
"application/vnd.xara": "CorelXARA",
"application/vnd.cosmocaller": "CosmoCaller",
"application/x-cpio": "CPIO Archive",
"application/vnd.crick.clicker": "CrickSoftware - Clicker",
"application/vnd.crick.clicker.keyboard": "CrickSoftware - Clicker - Keyboard",
"application/vnd.crick.clicker.palette": "CrickSoftware - Clicker - Palette",
"application/vnd.crick.clicker.template": "CrickSoftware - Clicker - Template",
"application/vnd.crick.clicker.wordbank": "CrickSoftware - Clicker - Wordbank",
"application/vnd.criticaltools.wbs+xml": "Critical Tools - PERT Chart EXPERT",
"application/vnd.rig.cryptonote": "CryptoNote",
"chemical/x-cif": "Crystallographic Interchange Format",
"chemical/x-cmdf": "CrystalMaker Data Format",
"application/cu-seeme": "CU-SeeMe",
"application/prs.cww": "CU-Writer",
"text/vnd.curl": "Curl - Applet",
"text/vnd.curl.dcurl": "Curl - Detached Applet",
"text/vnd.curl.mcurl": "Curl - Manifest File",
"text/vnd.curl.scurl": "Curl - Source Code",
"application/vnd.curl.car": "CURL Applet",
"application/vnd.curl.pcurl": "CURL Applet",
"application/vnd.yellowriver-custom-menu": "CustomMenu",
"application/dssc+der": "Data Structure for the Security Suitability of Cryptographic Algorithms",
"application/dssc+xml": "Data Structure for the Security Suitability of Cryptographic Algorithms",
"application/x-debian-package": "Debian Package",
"audio/vnd.dece.audio": "DECE Audio",
"image/vnd.dece.graphic": "DECE Graphic",
"video/vnd.dece.hd": "DECE High Definition Video",
"video/vnd.dece.mobile": "DECE Mobile Video",
"video/vnd.uvvu.mp4": "DECE MP4",
"video/vnd.dece.pd": "DECE PD Video",
"video/vnd.dece.sd": "DECE SD Video",
"video/vnd.dece.video": "DECE Video",
"application/x-dvi": "Device Independent File Format (DVI)",
"application/vnd.fdsn.seed": "Digital Siesmograph Networks - SEED Datafiles",
"application/x-dtbook+xml": "Digital Talking Book",
"application/x-dtbresource+xml": "Digital Talking Book - Resource File",
"application/vnd.dvb.ait": "Digital Video Broadcasting",
"application/vnd.dvb.service": "Digital Video Broadcasting",
"audio/vnd.digital-winds": "Digital Winds Music",
"image/vnd.djvu": "DjVu",
"application/xml-dtd": "Document Type Definition",
"application/vnd.dolby.mlp": "Dolby Meridian Lossless Packing",
"application/x-doom": "Doom Video Game",
"application/vnd.dpgraph": "DPGraph",
"audio/vnd.dra": "DRA Audio",
"application/vnd.dreamfactory": "DreamFactory",
"audio/vnd.dts": "DTS Audio",
"audio/vnd.dts.hd": "DTS High Definition Audio",
"image/vnd.dwg": "DWG Drawing",
"application/vnd.dynageo": "DynaGeo",
"application/ecmascript": "ECMAScript",
"application/vnd.ecowin.chart": "EcoWin Chart",
"image/vnd.fujixerox.edmics-mmr": "EDMICS 2000",
"image/vnd.fujixerox.edmics-rlc": "EDMICS 2000",
"application/exi": "Efficient XML Interchange",
"application/vnd.proteus.magazine": "EFI Proteus",
"application/epub+zip": "Electronic Publication",
"message/rfc822": "Email Message",
"application/vnd.enliven": "Enliven Viewer",
"application/vnd.is-xpr": "Express by Infoseek",
"image/vnd.xiff": "eXtended Image File Format (XIFF)",
"application/vnd.xfdl": "Extensible Forms Description Language",
"application/emma+xml": "Extensible MultiModal Annotation",
"application/vnd.ezpix-album": "EZPix Secure Photo Album",
"application/vnd.ezpix-package": "EZPix Secure Photo Album",
"image/vnd.fst": "FAST Search & Transfer ASA",
"video/vnd.fvt": "FAST Search & Transfer ASA",
"image/vnd.fastbidsheet": "FastBid Sheet",
"application/vnd.denovo.fcselayout-link": "FCS Express Layout Link",
"video/x-f4v": "Flash Video",
"video/x-flv": "Flash Video",
"image/vnd.fpx": "FlashPix",
"image/vnd.net-fpx": "FlashPix",
"text/vnd.fmi.flexstor": "FLEXSTOR",
"video/x-fli": "FLI/FLC Animation Format",
"application/vnd.fluxtime.clip": "FluxTime Clip",
"application/vnd.fdf": "Forms Data Format",
"text/x-fortran": "Fortran Source File",
"application/vnd.mif": "FrameMaker Interchange Format",
"application/vnd.framemaker": "FrameMaker Normal Format",
"image/x-freehand": "FreeHand MX",
"application/vnd.fsc.weblaunch": "Friendly Software Corporation",
"application/vnd.frogans.fnc": "Frogans Player",
"application/vnd.frogans.ltf": "Frogans Player",
"application/vnd.fujixerox.ddd": "Fujitsu - Xerox 2D CAD Data",
"application/vnd.fujixerox.docuworks": "Fujitsu - Xerox DocuWorks",
"application/vnd.fujixerox.docuworks.binder": "Fujitsu - Xerox DocuWorks Binder",
"application/vnd.fujitsu.oasys": "Fujitsu Oasys",
"application/vnd.fujitsu.oasys2": "Fujitsu Oasys",
"application/vnd.fujitsu.oasys3": "Fujitsu Oasys",
"application/vnd.fujitsu.oasysgp": "Fujitsu Oasys",
"application/vnd.fujitsu.oasysprs": "Fujitsu Oasys",
"application/x-futuresplash": "FutureSplash Animator",
"application/vnd.fuzzysheet": "FuzzySheet",
"image/g3fax": "G3 Fax Image",
"application/vnd.gmx": "GameMaker ActiveX",
"model/vnd.gtw": "Gen-Trix Studio",
"application/vnd.genomatix.tuxedo": "Genomatix Tuxedo Framework",
"application/vnd.geogebra.file": "GeoGebra",
"application/vnd.geogebra.tool": "GeoGebra",
"model/vnd.gdl": "Geometric Description Language (GDL)",
"application/vnd.geometry-explorer": "GeoMetry Explorer",
"application/vnd.geonext": "GEONExT and JSXGraph",
"application/vnd.geoplan": "GeoplanW",
"application/vnd.geospace": "GeospacW",
"application/x-font-ghostscript": "Ghostscript Font",
"application/x-font-bdf": "Glyph Bitmap Distribution Format",
"application/x-gtar": "GNU Tar Files",
"application/x-texinfo": "GNU Texinfo Document",
"application/x-gnumeric": "Gnumeric",
"application/vnd.google-earth.kml+xml": "Google Earth - KML",
"application/vnd.google-earth.kmz": "Google Earth - Zipped KML",
"application/vnd.grafeq": "GrafEq",
"image/gif": "Graphics Interchange Format",
"text/vnd.graphviz": "Graphviz",
"application/vnd.groove-account": "Groove - Account",
"application/vnd.groove-help": "Groove - Help",
"application/vnd.groove-identity-message": "Groove - Identity Message",
"application/vnd.groove-injector": "Groove - Injector",
"application/vnd.groove-tool-message": "Groove - Tool Message",
"application/vnd.groove-tool-template": "Groove - Tool Template",
"application/vnd.groove-vcard": "Groove - Vcard",
"video/h261": "H.261",
"video/h263": "H.263",
"video/h264": "H.264",
"application/vnd.hp-hpid": "Hewlett Packard Instant Delivery",
"application/vnd.hp-hps": "Hewlett-Packard's WebPrintSmart",
"application/x-hdf": "Hierarchical Data Format",
"audio/vnd.rip": "Hit'n'Mix",
"application/vnd.hbci": "Homebanking Computer Interface (HBCI)",
"application/vnd.hp-jlyt": "HP Indigo Digital Press - Job Layout Languate",
"application/vnd.hp-pcl": "HP Printer Command Language",
"application/vnd.hp-hpgl": "HP-GL/2 and HP RTL",
"application/vnd.yamaha.hv-script": "HV Script",
"application/vnd.yamaha.hv-dic": "HV Voice Dictionary",
"application/vnd.yamaha.hv-voice": "HV Voice Parameter",
"application/vnd.hydrostatix.sof-data": "Hydrostatix Master Suite",
"application/hyperstudio": "Hyperstudio",
"application/vnd.hal+xml": "Hypertext Application Language",
"text/html": "HyperText Markup Language (HTML)",
"application/vnd.ibm.rights-management": "IBM DB2 Rights Manager",
"application/vnd.ibm.secure-container": "IBM Electronic Media Management System - Secure Container",
"text/calendar": "iCalendar",
"application/vnd.iccprofile": "ICC profile",
"image/x-icon": "Icon Image",
"application/vnd.igloader": "igLoader",
"image/ief": "Image Exchange Format",
"application/vnd.immervision-ivp": "ImmerVision PURE Players",
"application/vnd.immervision-ivu": "ImmerVision PURE Players",
"application/reginfo+xml": "IMS Networks",
"text/vnd.in3d.3dml": "In3D - 3DML",
"text/vnd.in3d.spot": "In3D - 3DML",
"model/iges": "Initial Graphics Exchange Specification (IGES)",
"application/vnd.intergeo": "Interactive Geometry Software",
"application/vnd.cinderella": "Interactive Geometry Software Cinderella",
"application/vnd.intercon.formnet": "Intercon FormNet",
"application/vnd.isac.fcs": "International Society for Advancement of Cytometry",
"application/ipfix": "Internet Protocol Flow Information Export",
"application/pkix-cert": "Internet Public Key Infrastructure - Certificate",
"application/pkixcmp": "Internet Public Key Infrastructure - Certificate Management Protocole",
"application/pkix-crl": "Internet Public Key Infrastructure - Certificate Revocation Lists",
"application/pkix-pkipath": "Internet Public Key Infrastructure - Certification Path",
"application/vnd.insors.igm": "IOCOM Visimeet",
"application/vnd.ipunplugged.rcprofile": "IP Unplugged Roaming Client",
"application/vnd.irepository.package+xml": "iRepository / Lucidoc Editor",
"text/vnd.sun.j2me.app-descriptor": "J2ME App Descriptor",
"application/java-archive": "Java Archive",
"application/java-vm": "Java Bytecode File",
"application/x-java-jnlp-file": "Java Network Launching Protocol",
"application/java-serialized-object": "Java Serialized Object",
"text/x-java-source,java": "Java Source File",
"application/javascript": "JavaScript",
"application/json": "JavaScript Object Notation (JSON)",
"application/vnd.joost.joda-archive": "Joda Archive",
"video/jpm": "JPEG 2000 Compound Image File Format",
"image/jpeg": "JPEG Image",
"image/x-citrix-jpeg": "JPEG Image (Citrix client)",
"image/pjpeg": "JPEG Image (Progressive)",
"video/jpeg": "JPGVideo",
"application/vnd.kahootz": "Kahootz",
"application/vnd.chipnuts.karaoke-mmd": "Karaoke on Chipnuts Chipsets",
"application/vnd.kde.karbon": "KDE KOffice Office Suite - Karbon",
"application/vnd.kde.kchart": "KDE KOffice Office Suite - KChart",
"application/vnd.kde.kformula": "KDE KOffice Office Suite - Kformula",
"application/vnd.kde.kivio": "KDE KOffice Office Suite - Kivio",
"application/vnd.kde.kontour": "KDE KOffice Office Suite - Kontour",
"application/vnd.kde.kpresenter": "KDE KOffice Office Suite - Kpresenter",
"application/vnd.kde.kspread": "KDE KOffice Office Suite - Kspread",
"application/vnd.kde.kword": "KDE KOffice Office Suite - Kword",
"application/vnd.kenameaapp": "Kenamea App",
"application/vnd.kidspiration": "Kidspiration",
"application/vnd.kinar": "Kinar Applications",
"application/vnd.kodak-descriptor": "Kodak Storyshare",
"application/vnd.las.las+xml": "Laser App Enterprise",
"application/x-latex": "LaTeX",
"application/vnd.llamagraphics.life-balance.desktop": "Life Balance - Desktop Edition",
"application/vnd.llamagraphics.life-balance.exchange+xml": "Life Balance - Exchange Format",
"application/vnd.jam": "Lightspeed Audio Lab",
"application/vnd.lotus-1-2-3": "Lotus 1-2-3",
"application/vnd.lotus-approach": "Lotus Approach",
"application/vnd.lotus-freelance": "Lotus Freelance",
"application/vnd.lotus-notes": "Lotus Notes",
"application/vnd.lotus-organizer": "Lotus Organizer",
"application/vnd.lotus-screencam": "Lotus Screencam",
"application/vnd.lotus-wordpro": "Lotus Wordpro",
"audio/vnd.lucent.voice": "Lucent Voice",
"audio/x-mpegurl": "M3U (Multimedia Playlist)",
"video/x-m4v": "M4v",
"application/mac-binhex40": "Macintosh BinHex 4.0",
"application/vnd.macports.portpkg": "MacPorts Port System",
"application/vnd.osgeo.mapguide.package": "MapGuide DBXML",
"application/marc": "MARC Formats",
"application/marcxml+xml": "MARC21 XML Schema",
"application/mxf": "Material Exchange Format",
"application/vnd.wolfram.player": "Mathematica Notebook Player",
"application/mathematica": "Mathematica Notebooks",
"application/mathml+xml": "Mathematical Markup Language",
"application/mbox": "Mbox database files",
"application/vnd.medcalcdata": "MedCalc",
"application/mediaservercontrol+xml": "Media Server Control Markup Language",
"application/vnd.mediastation.cdkey": "MediaRemote",
"application/vnd.mfer": "Medical Waveform Encoding Format",
"application/vnd.mfmp": "Melody Format for Mobile Platform",
"model/mesh": "Mesh Data Type",
"application/mads+xml": "Metadata Authority Description Schema",
"application/mets+xml": "Metadata Encoding and Transmission Standard",
"application/mods+xml": "Metadata Object Description Schema",
"application/metalink4+xml": "Metalink",
"application/vnd.mcd": "Micro CADAM Helix D&D",
"application/vnd.micrografx.flo": "Micrografx",
"application/vnd.micrografx.igx": "Micrografx iGrafx Professional",
"application/vnd.eszigno3+xml": "MICROSEC e-Szign¢",
"application/x-msaccess": "Microsoft Access",
"video/x-ms-asf": "Microsoft Advanced Systems Format (ASF)",
"application/x-msdownload": "Microsoft Application",
"application/vnd.ms-artgalry": "Microsoft Artgalry",
"application/vnd.ms-cab-compressed": "Microsoft Cabinet File",
"application/vnd.ms-ims": "Microsoft Class Server",
"application/x-ms-application": "Microsoft ClickOnce",
"application/x-msclip": "Microsoft Clipboard Clip",
"image/vnd.ms-modi": "Microsoft Document Imaging Format",
"application/vnd.ms-fontobject": "Microsoft Embedded OpenType",
"application/vnd.ms-excel": "Microsoft Excel",
"application/vnd.ms-excel.addin.macroenabled.12": "Microsoft Excel - Add-In File",
"application/vnd.ms-excel.sheet.binary.macroenabled.12": "Microsoft Excel - Binary Workbook",
"application/vnd.ms-excel.template.macroenabled.12": "Microsoft Excel - Macro-Enabled Template File",
"application/vnd.ms-excel.sheet.macroenabled.12": "Microsoft Excel - Macro-Enabled Workbook",
"application/vnd.ms-htmlhelp": "Microsoft Html Help File",
"application/x-mscardfile": "Microsoft Information Card",
"application/vnd.ms-lrm": "Microsoft Learning Resource Module",
"application/x-msmediaview": "Microsoft MediaView",
"application/x-msmoney": "Microsoft Money",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "Microsoft Office - OOXML - Presentation",
"application/vnd.openxmlformats-officedocument.presentationml.slide": "Microsoft Office - OOXML - Presentation (Slide)",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow": "Microsoft Office - OOXML - Presentation (Slideshow)",
"application/vnd.openxmlformats-officedocument.presentationml.template": "Microsoft Office - OOXML - Presentation Template",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Microsoft Office - OOXML - Spreadsheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template": "Microsoft Office - OOXML - Spreadsheet Template",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Microsoft Office - OOXML - Word Document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template": "Microsoft Office - OOXML - Word Document Template",
"application/x-msbinder": "Microsoft Office Binder",
"application/vnd.ms-officetheme": "Microsoft Office System Release Theme",
"application/onenote": "Microsoft OneNote",
"audio/vnd.ms-playready.media.pya": "Microsoft PlayReady Ecosystem",
"video/vnd.ms-playready.media.pyv": "Microsoft PlayReady Ecosystem Video",
"application/vnd.ms-powerpoint": "Microsoft PowerPoint",
"application/vnd.ms-powerpoint.addin.macroenabled.12": "Microsoft PowerPoint - Add-in file",
"application/vnd.ms-powerpoint.slide.macroenabled.12": "Microsoft PowerPoint - Macro-Enabled Open XML Slide",
"application/vnd.ms-powerpoint.presentation.macroenabled.12": "Microsoft PowerPoint - Macro-Enabled Presentation File",
"application/vnd.ms-powerpoint.slideshow.macroenabled.12": "Microsoft PowerPoint - Macro-Enabled Slide Show File",
"application/vnd.ms-powerpoint.template.macroenabled.12": "Microsoft PowerPoint - Macro-Enabled Template File",
"application/vnd.ms-project": "Microsoft Project",
"application/x-mspublisher": "Microsoft Publisher",
"application/x-msschedule": "Microsoft Schedule+",
"application/x-silverlight-app": "Microsoft Silverlight",
"application/vnd.ms-pki.stl": "Microsoft Trust UI Provider - Certificate Trust Link",
"application/vnd.ms-pki.seccat": "Microsoft Trust UI Provider - Security Catalog",
"application/vnd.visio": "Microsoft Visio",
"application/vnd.visio2013": "Microsoft Visio 2013",
"video/x-ms-wm": "Microsoft Windows Media",
"audio/x-ms-wma": "Microsoft Windows Media Audio",
"audio/x-ms-wax": "Microsoft Windows Media Audio Redirector",
"video/x-ms-wmx": "Microsoft Windows Media Audio/Video Playlist",
"application/x-ms-wmd": "Microsoft Windows Media Player Download Package",
"application/vnd.ms-wpl": "Microsoft Windows Media Player Playlist",
"application/x-ms-wmz": "Microsoft Windows Media Player Skin Package",
"video/x-ms-wmv": "Microsoft Windows Media Video",
"video/x-ms-wvx": "Microsoft Windows Media Video Playlist",
"application/x-msmetafile": "Microsoft Windows Metafile",
"application/x-msterminal": "Microsoft Windows Terminal Services",
"application/msword": "Microsoft Word",
"application/vnd.ms-word.document.macroenabled.12": "Microsoft Word - Macro-Enabled Document",
"application/vnd.ms-word.template.macroenabled.12": "Microsoft Word - Macro-Enabled Template",
"application/x-mswrite": "Microsoft Wordpad",
"application/vnd.ms-works": "Microsoft Works",
"application/x-ms-xbap": "Microsoft XAML Browser Application",
"application/vnd.ms-xpsdocument": "Microsoft XML Paper Specification",
"audio/midi": "MIDI - Musical Instrument Digital Interface",
"application/vnd.ibm.minipay": "MiniPay",
"application/vnd.ibm.modcap": "MO:DCA-P",
"application/vnd.jcp.javame.midlet-rms": "Mobile Information Device Profile",
"application/vnd.tmobile-livetv": "MobileTV",
"application/x-mobipocket-ebook": "Mobipocket",
"application/vnd.mobius.mbk": "Mobius Management Systems - Basket file",
"application/vnd.mobius.dis": "Mobius Management Systems - Distribution Database",
"application/vnd.mobius.plc": "Mobius Management Systems - Policy Definition Language File",
"application/vnd.mobius.mqy": "Mobius Management Systems - Query File",
"application/vnd.mobius.msl": "Mobius Management Systems - Script Language",
"application/vnd.mobius.txf": "Mobius Management Systems - Topic Index File",
"application/vnd.mobius.daf": "Mobius Management Systems - UniversalArchive",
"text/vnd.fly": "mod_fly / fly.cgi",
"application/vnd.mophun.certificate": "Mophun Certificate",
"application/vnd.mophun.application": "Mophun VM",
"video/mj2": "Motion JPEG 2000",
"audio/mpeg": "MPEG Audio",
"video/vnd.mpegurl": "MPEG Url",
"video/mpeg": "MPEG Video",
"application/mp21": "MPEG-21",
"audio/mp4": "MPEG-4 Audio",
"video/mp4": "MPEG-4 Video",
"application/mp4": "MPEG4",
"application/vnd.apple.mpegurl": "Multimedia Playlist Unicode",
"application/vnd.musician": "MUsical Score Interpreted Code Invented for the ASCII designation of Notation",
"application/vnd.muvee.style": "Muvee Automatic Video Editing",
"application/xv+xml": "MXML",
"application/vnd.nokia.n-gage.data": "N-Gage Game Data",
"application/vnd.nokia.n-gage.symbian.install": "N-Gage Game Installer",
"application/x-dtbncx+xml": "Navigation Control file for XML (for ePub)",
"application/x-netcdf": "Network Common Data Form (NetCDF)",
"application/vnd.neurolanguage.nlu": "neuroLanguage",
"application/vnd.dna": "New Moon Liftoff/DNA",
"application/vnd.noblenet-directory": "NobleNet Directory",
"application/vnd.noblenet-sealer": "NobleNet Sealer",
"application/vnd.noblenet-web": "NobleNet Web",
"application/vnd.nokia.radio-preset": "Nokia Radio Application - Preset",
"application/vnd.nokia.radio-presets": "Nokia Radio Application - Preset",
"text/n3": "Notation3",
"application/vnd.novadigm.edm": "Novadigm's RADIA and EDM products",
"application/vnd.novadigm.edx": "Novadigm's RADIA and EDM products",
"application/vnd.novadigm.ext": "Novadigm's RADIA and EDM products",
"application/vnd.flographit": "NpGraphIt",
"audio/vnd.nuera.ecelp4800": "Nuera ECELP 4800",
"audio/vnd.nuera.ecelp7470": "Nuera ECELP 7470",
"audio/vnd.nuera.ecelp9600": "Nuera ECELP 9600",
"application/oda": "Office Document Architecture",
"application/ogg": "Ogg",
"audio/ogg": "Ogg Audio",
"video/ogg": "Ogg Video",
"application/vnd.oma.dd2+xml": "OMA Download Agents",
"application/vnd.oasis.opendocument.text-web": "Open Document Text Web",
"application/oebps-package+xml": "Open eBook Publication Structure",
"application/vnd.intu.qbo": "Open Financial Exchange",
"application/vnd.openofficeorg.extension": "Open Office Extension",
"application/vnd.yamaha.openscoreformat": "Open Score Format",
"audio/webm": "Open Web Media Project - Audio",
"video/webm": "Open Web Media Project - Video",
"application/vnd.oasis.opendocument.chart": "OpenDocument Chart",
"application/vnd.oasis.opendocument.chart-template": "OpenDocument Chart Template",
"application/vnd.oasis.opendocument.database": "OpenDocument Database",
"application/vnd.oasis.opendocument.formula": "OpenDocument Formula",
"application/vnd.oasis.opendocument.formula-template": "OpenDocument Formula Template",
"application/vnd.oasis.opendocument.graphics": "OpenDocument Graphics",
"application/vnd.oasis.opendocument.graphics-template": "OpenDocument Graphics Template",
"application/vnd.oasis.opendocument.image": "OpenDocument Image",
"application/vnd.oasis.opendocument.image-template": "OpenDocument Image Template",
"application/vnd.oasis.opendocument.presentation": "OpenDocument Presentation",
"application/vnd.oasis.opendocument.presentation-template": "OpenDocument Presentation Template",
"application/vnd.oasis.opendocument.spreadsheet": "OpenDocument Spreadsheet",
"application/vnd.oasis.opendocument.spreadsheet-template": "OpenDocument Spreadsheet Template",
"application/vnd.oasis.opendocument.text": "OpenDocument Text",
"application/vnd.oasis.opendocument.text-master": "OpenDocument Text Master",
"application/vnd.oasis.opendocument.text-template": "OpenDocument Text Template",
"image/ktx": "OpenGL Textures (KTX)",
"application/vnd.sun.xml.calc": "OpenOffice - Calc (Spreadsheet)",
"application/vnd.sun.xml.calc.template": "OpenOffice - Calc Template (Spreadsheet)",
"application/vnd.sun.xml.draw": "OpenOffice - Draw (Graphics)",
"application/vnd.sun.xml.draw.template": "OpenOffice - Draw Template (Graphics)",
"application/vnd.sun.xml.impress": "OpenOffice - Impress (Presentation)",
"application/vnd.sun.xml.impress.template": "OpenOffice - Impress Template (Presentation)",
"application/vnd.sun.xml.math": "OpenOffice - Math (Formula)",
"application/vnd.sun.xml.writer": "OpenOffice - Writer (Text - HTML)",
"application/vnd.sun.xml.writer.global": "OpenOffice - Writer (Text - HTML)",
"application/vnd.sun.xml.writer.template": "OpenOffice - Writer Template (Text - HTML)",
"application/x-font-otf": "OpenType Font File",
"application/vnd.yamaha.openscoreformat.osfpvg+xml": "OSFPVG",
"application/vnd.osgi.dp": "OSGi Deployment Package",
"application/vnd.palm": "PalmOS Data",
"text/x-pascal": "Pascal Source File",
"application/vnd.pawaafile": "PawaaFILE",
"application/vnd.hp-pclxl": "PCL 6 Enhanced (Formely PCL XL)",
"application/vnd.picsel": "Pcsel eFIF File",
"image/x-pcx": "PCX Image",
"image/vnd.adobe.photoshop": "Photoshop Document",
"application/pics-rules": "PICSRules",
"image/x-pict": "PICT Image",
"application/x-chat": "pIRCh",
"application/pkcs10": "PKCS #10 - Certification Request Standard",
"application/x-pkcs12": "PKCS #12 - Personal Information Exchange Syntax Standard",
"application/pkcs7-mime": "PKCS #7 - Cryptographic Message Syntax Standard",
"application/pkcs7-signature": "PKCS #7 - Cryptographic Message Syntax Standard",
"application/x-pkcs7-certreqresp": "PKCS #7 - Cryptographic Message Syntax Standard (Certificate Request Response)",
"application/x-pkcs7-certificates": "PKCS #7 - Cryptographic Message Syntax Standard (Certificates)",
"application/pkcs8": "PKCS #8 - Private-Key Information Syntax Standard",
"application/vnd.pocketlearn": "PocketLearn Viewers",
"image/x-portable-anymap": "Portable Anymap Image",
"image/x-portable-bitmap": "Portable Bitmap Format",
"application/x-font-pcf": "Portable Compiled Format",
"application/font-tdpfr": "Portable Font Resource",
"application/x-chess-pgn": "Portable Game Notation (Chess Games)",
"image/x-portable-graymap": "Portable Graymap Format",
"image/png": "Portable Network Graphics (PNG)",
"image/x-citrix-png": "Portable Network Graphics (PNG) (Citrix client)",
"image/x-png": "Portable Network Graphics (PNG) (x-token)",
"image/x-portable-pixmap": "Portable Pixmap Format",
"application/pskc+xml": "Portable Symmetric Key Container",
"application/vnd.ctc-posml": "PosML",
"application/postscript": "PostScript",
"application/x-font-type1": "PostScript Fonts",
"application/vnd.powerbuilder6": "PowerBuilder",
"application/pgp-encrypted": "Pretty Good Privacy",
"application/pgp-signature": "Pretty Good Privacy - Signature",
"application/vnd.previewsystems.box": "Preview Systems ZipLock/VBox",
"application/vnd.pvi.ptid1": "Princeton Video Image",
"application/pls+xml": "Pronunciation Lexicon Specification",
"application/vnd.pg.format": "Proprietary P&G Standard Reporting System",
"application/vnd.pg.osasli": "Proprietary P&G Standard Reporting System",
"text/prs.lines.tag": "PRS Lines Tag",
"application/x-font-linux-psf": "PSF Fonts",
"application/vnd.publishare-delta-tree": "PubliShare Objects",
"application/vnd.pmi.widget": "Qualcomm's Plaza Mobile Internet",
"application/vnd.quark.quarkxpress": "QuarkXpress",
"application/vnd.epson.esf": "QUASS Stream Player",
"application/vnd.epson.msf": "QUASS Stream Player",
"application/vnd.epson.ssf": "QUASS Stream Player",
"application/vnd.epson.quickanime": "QuickAnime Player",
"application/vnd.intu.qfx": "Quicken",
"video/quicktime": "Quicktime Video",
"application/x-rar-compressed": "RAR Archive",
"audio/x-pn-realaudio": "Real Audio Sound",
"audio/x-pn-realaudio-plugin": "Real Audio Sound",
"application/rsd+xml": "Really Simple Discovery",
"application/vnd.rn-realmedia": "RealMedia",
"application/vnd.realvnc.bed": "RealVNC",
"application/vnd.recordare.musicxml": "Recordare Applications",
"application/vnd.recordare.musicxml+xml": "Recordare Applications",
"application/relax-ng-compact-syntax": "Relax NG Compact Syntax",
"application/vnd.data-vision.rdz": "RemoteDocs R-Viewer",
"application/rdf+xml": "Resource Description Framework",
"application/vnd.cloanto.rp9": "RetroPlatform Player",
"application/vnd.jisp": "RhymBox",
"application/rtf": "Rich Text Format",
"text/richtext": "Rich Text Format (RTF)",
"application/vnd.route66.link66+xml": "ROUTE 66 Location Based Services",
"application/rss+xml": "RSS - Really Simple Syndication",
"application/shf+xml": "S Hexdump Format",
"application/vnd.sailingtracker.track": "SailingTracker",
"image/svg+xml": "Scalable Vector Graphics (SVG)",
"application/vnd.sus-calendar": "ScheduleUs",
"application/sru+xml": "Search/Retrieve via URL Response Format",
"application/set-payment-initiation": "Secure Electronic Transaction - Payment",
"application/set-registration-initiation": "Secure Electronic Transaction - Registration",
"application/vnd.sema": "Secured eMail",
"application/vnd.semd": "Secured eMail",
"application/vnd.semf": "Secured eMail",
"application/vnd.seemail": "SeeMail",
"application/x-font-snf": "Server Normal Format",
"application/scvp-vp-request": "Server-Based Certificate Validation Protocol - Validation Policies - Request",
"application/scvp-vp-response": "Server-Based Certificate Validation Protocol - Validation Policies - Response",
"application/scvp-cv-request": "Server-Based Certificate Validation Protocol - Validation Request",
"application/scvp-cv-response": "Server-Based Certificate Validation Protocol - Validation Response",
"application/sdp": "Session Description Protocol",
"text/x-setext": "Setext",
"video/x-sgi-movie": "SGI Movie",
"application/vnd.shana.informed.formdata": "Shana Informed Filler",
"application/vnd.shana.informed.formtemplate": "Shana Informed Filler",
"application/vnd.shana.informed.interchange": "Shana Informed Filler",
"application/vnd.shana.informed.package": "Shana Informed Filler",
"application/thraud+xml": "Sharing Transaction Fraud Data",
"application/x-shar": "Shell Archive",
"image/x-rgb": "Silicon Graphics RGB Bitmap",
"application/vnd.epson.salt": "SimpleAnimeLite Player",
"application/vnd.accpac.simply.aso": "Simply Accounting",
"application/vnd.accpac.simply.imp": "Simply Accounting - Data Import",
"application/vnd.simtech-mindmapper": "SimTech MindMapper",
"application/vnd.commonspace": "Sixth Floor Media - CommonSpace",
"application/vnd.yamaha.smaf-audio": "SMAF Audio",
"application/vnd.smaf": "SMAF File",
"application/vnd.yamaha.smaf-phrase": "SMAF Phrase",
"application/vnd.smart.teacher": "SMART Technologies Apps",
"application/vnd.svd": "SourceView Document",
"application/sparql-query": "SPARQL - Query",
"application/sparql-results+xml": "SPARQL - Results",
"application/srgs": "Speech Recognition Grammar Specification",
"application/srgs+xml": "Speech Recognition Grammar Specification - XML",
"application/ssml+xml": "Speech Synthesis Markup Language",
"application/vnd.koan": "SSEYO Koan Play File",
"text/sgml": "Standard Generalized Markup Language (SGML)",
"application/vnd.stardivision.calc": "StarOffice - Calc",
"application/vnd.stardivision.draw": "StarOffice - Draw",
"application/vnd.stardivision.impress": "StarOffice - Impress",
"application/vnd.stardivision.math": "StarOffice - Math",
"application/vnd.stardivision.writer": "StarOffice - Writer",
"application/vnd.stardivision.writer-global": "StarOffice - Writer (Global)",
"application/vnd.stepmania.stepchart": "StepMania",
"application/x-stuffit": "Stuffit Archive",
"application/x-stuffitx": "Stuffit Archive",
"application/vnd.solent.sdkm+xml": "SudokuMagic",
"application/vnd.olpc-sugar": "Sugar Linux Application Bundle",
"audio/basic": "Sun Audio - Au file format",
"application/vnd.wqd": "SundaHus WQ",
"application/vnd.symbian.install": "Symbian Install Package",
"application/smil+xml": "Synchronized Multimedia Integration Language",
"application/vnd.syncml+xml": "SyncML",
"application/vnd.syncml.dm+wbxml": "SyncML - Device Management",
"application/vnd.syncml.dm+xml": "SyncML - Device Management",
"application/x-sv4cpio": "System V Release 4 CPIO Archive",
"application/x-sv4crc": "System V Release 4 CPIO Checksum Data",
"application/sbml+xml": "Systems Biology Markup Language",
"text/tab-separated-values": "Tab Seperated Values",
"image/tiff": "Tagged Image File Format",
"application/vnd.tao.intent-module-archive": "Tao Intent",
"application/x-tar": "Tar File (Tape Archive)",
"application/x-tcl": "Tcl Script",
"application/x-tex": "TeX",
"application/x-tex-tfm": "TeX Font Metric",
"application/tei+xml": "Text Encoding and Interchange",
"text/plain": "Text File",
"application/vnd.spotfire.dxp": "TIBCO Spotfire",
"application/vnd.spotfire.sfs": "TIBCO Spotfire",
"application/timestamped-data": "Time Stamped Data Envelope",
"application/vnd.trid.tpt": "TRI Systems Config",
"application/vnd.triscape.mxs": "Triscape Map Explorer",
"text/troff": "troff",
"application/vnd.trueapp": "True BASIC",
"application/x-font-ttf": "TrueType Font",
"text/turtle": "Turtle (Terse RDF Triple Language)",
"application/vnd.umajin": "UMAJIN",
"application/vnd.uoml+xml": "Unique Object Markup Language",
"application/vnd.unity": "Unity 3d",
"application/vnd.ufdl": "Universal Forms Description Language",
"text/uri-list": "URI Resolution Services",
"application/vnd.uiq.theme": "User Interface Quartz - Theme (Symbian)",
"application/x-ustar": "Ustar (Uniform Standard Tape Archive)",
"text/x-uuencode": "UUEncode",
"text/x-vcalendar": "vCalendar",
"text/x-vcard": "vCard",
"application/x-cdlink": "Video CD",
"application/vnd.vsf": "Viewport+",
"model/vrml": "Virtual Reality Modeling Language",
"application/vnd.vcx": "VirtualCatalog",
"model/vnd.mts": "Virtue MTS",
"model/vnd.vtu": "Virtue VTU",
"application/vnd.visionary": "Visionary",
"video/vnd.vivo": "Vivo",
"application/ccxml+xml,": "Voice Browser Call Control",
"application/voicexml+xml": "VoiceXML",
"application/x-wais-source": "WAIS Source",
"application/vnd.wap.wbxml": "WAP Binary XML (WBXML)",
"image/vnd.wap.wbmp": "WAP Bitamp (WBMP)",
"audio/x-wav": "Waveform Audio File Format (WAV)",
"application/davmount+xml": "Web Distributed Authoring and Versioning",
"application/x-font-woff": "Web Open Font Format",
"application/wspolicy+xml": "Web Services Policy",
"image/webp": "WebP Image",
"application/vnd.webturbo": "WebTurbo",
"application/widget": "Widget Packaging and XML Configuration",
"application/winhlp": "WinHelp",
"text/vnd.wap.wml": "Wireless Markup Language (WML)",
"text/vnd.wap.wmlscript": "Wireless Markup Language Script (WMLScript)",
"application/vnd.wap.wmlscriptc": "WMLScript",
"application/vnd.wordperfect": "Wordperfect",
"application/vnd.wt.stf": "Worldtalk",
"application/wsdl+xml": "WSDL - Web Services Description Language",
"image/x-xbitmap": "X BitMap",
"image/x-xpixmap": "X PixMap",
"image/x-xwindowdump": "X Window Dump",
"application/x-x509-ca-cert": "X.509 Certificate",
"application/x-xfig": "Xfig",
"application/xhtml+xml": "XHTML - The Extensible HyperText Markup Language",
"application/xml": "XML - Extensible Markup Language",
"application/xcap-diff+xml": "XML Configuration Access Protocol - XCAP Diff",
"application/xenc+xml": "XML Encryption Syntax and Processing",
"application/patch-ops-error+xml": "XML Patch Framework",
"application/resource-lists+xml": "XML Resource Lists",
"application/rls-services+xml": "XML Resource Lists",
"application/resource-lists-diff+xml": "XML Resource Lists Diff",
"application/xslt+xml": "XML Transformations",
"application/xop+xml": "XML-Binary Optimized Packaging",
"application/x-xpinstall": "XPInstall - Mozilla",
"application/xspf+xml": "XSPF - XML Shareable Playlist Format",
"application/vnd.mozilla.xul+xml": "XUL - XML User Interface Language",
"chemical/x-xyz": "XYZ File Format",
"text/yaml": "YAML Ain't Markup Language / Yet Another Markup Language",
"application/yang": "YANG Data Modeling Language",
"application/yin+xml": "YIN (YANG - XML)",
"application/vnd.zul": "Z.U.L. Geometry",
"application/zip": "Zip Archive",
"application/vnd.handheld-entertainment+xml": "ZVUE Media Manager",
"application/vnd.zzazz.deck+xml": "Zzazz Deck"
}

View File

@@ -14,7 +14,7 @@
{{ activity.action_by.first_name }} {{ activity.action_by.last_name }}
</template>
<template v-else-if="activity.action_by && action.action_by">
<template v-else>
{{ $t('private_user') }}
</template>
</div>

View File

@@ -4,14 +4,25 @@
<v-textarea v-if="editing" v-model="edits">
<template #append>
<v-button :loading="savingEdits" class="post-comment" @click="saveEdits" x-small>
{{ $t('save') }}
</v-button>
<div class="buttons">
<v-button class="cancel" @click="cancelEditing" secondary x-small>
{{ $t('cancel') }}
</v-button>
<v-button
:loading="savingEdits"
class="post-comment"
@click="saveEdits"
x-small
>
{{ $t('save') }}
</v-button>
</div>
</template>
</v-textarea>
<div v-else class="content">
<span v-html="htmlContent" />
<span v-html="htmlContent" class="selectable" />
<!-- @TODO: Dynamically add element below if the comment overflows -->
<!-- <div v-if="activity.id == 204" class="expand-text">
@@ -48,9 +59,9 @@ export default defineComponent({
props.activity.comment ? marked(props.activity.comment) : null
);
const { edits, editing, savingEdits, saveEdits } = useEdits();
const { edits, editing, savingEdits, saveEdits, cancelEditing } = useEdits();
return { htmlContent, edits, editing, savingEdits, saveEdits };
return { htmlContent, edits, editing, savingEdits, saveEdits, cancelEditing };
function useEdits() {
const edits = ref(props.activity.comment);
@@ -62,7 +73,7 @@ export default defineComponent({
() => (edits.value = props.activity.comment)
);
return { edits, editing, savingEdits, saveEdits };
return { edits, editing, savingEdits, saveEdits, cancelEditing };
async function saveEdits() {
const { currentProjectKey } = projectsStore.state;
@@ -80,6 +91,11 @@ export default defineComponent({
editing.value = false;
}
}
function cancelEditing() {
edits.value = props.activity.comment;
editing.value = false;
}
}
},
});
@@ -196,9 +212,13 @@ export default defineComponent({
}
}
.post-comment {
.buttons {
position: absolute;
right: 8px;
bottom: 8px;
}
.cancel {
margin-right: 4px;
}
</style>

View File

@@ -1,8 +1,9 @@
import markdown from './readme.md';
import { defineComponent, provide, toRefs } from '@vue/composition-api';
import { defineComponent, watch } from '@vue/composition-api';
import { withKnobs, boolean } from '@storybook/addon-knobs';
import withPadding from '../../../../../.storybook/decorators/with-padding';
import withAltColors from '../../../../../.storybook/decorators/with-alt-colors';
import useAppStore from '@/stores/app';
import DrawerButton from './drawer-button.vue';
@@ -23,7 +24,16 @@ export const basic = () =>
},
},
setup(props) {
provide('drawer-open', toRefs(props).drawerOpen);
const appStore = useAppStore();
appStore.state.drawerOpen = props.drawerOpen;
watch(
() => props.drawerOpen,
(newOpen) => {
appStore.state.drawerOpen = newOpen;
}
);
},
template: `
<drawer-button icon="info">Close Drawer</drawer-button>

View File

@@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VIcon from '@/components/v-icon/';
import DrawerButton from './drawer-button.vue';
import useAppStore from '@/stores/app';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
@@ -9,11 +10,11 @@ localVue.component('v-icon', VIcon);
describe('Views / Private / Components / Drawer Button', () => {
it('Does not render the title when the drawer is closed', () => {
const appStore = useAppStore();
appStore.state.drawerOpen = false;
const component = shallowMount(DrawerButton, {
localVue,
provide: {
'drawer-open': false,
},
});
expect(component.find('.title').exists()).toBe(false);

View File

@@ -6,7 +6,7 @@
@click="$emit('click', $event)"
>
<div class="icon">
<v-icon :name="icon" />
<v-icon :name="icon" outline />
</div>
<div class="title" v-if="drawerOpen">
<slot />
@@ -15,7 +15,8 @@
</template>
<script lang="ts">
import { defineComponent, inject, ref } from '@vue/composition-api';
import { defineComponent, toRefs } from '@vue/composition-api';
import useAppStore from '@/stores/app';
export default defineComponent({
props: {
@@ -33,7 +34,8 @@ export default defineComponent({
},
},
setup() {
const drawerOpen = inject('drawer-open', ref(false));
const appStore = useAppStore();
const { drawerOpen } = toRefs(appStore.state);
return { drawerOpen };
},

View File

@@ -3,9 +3,10 @@ import markdown from './readme.md';
import withPadding from '../../../../../.storybook/decorators/with-padding';
import withAltColors from '../../../../../.storybook/decorators/with-alt-colors';
import { defineComponent, provide } from '@vue/composition-api';
import { defineComponent } from '@vue/composition-api';
import DrawerDetailGroup from './drawer-detail-group.vue';
import DrawerDetail from '../drawer-detail';
import useAppStore from '@/stores/app';
export default {
title: 'Views / Private / Components / Drawer Detail Group',
@@ -19,7 +20,8 @@ export const basic = () =>
defineComponent({
components: { DrawerDetailGroup, DrawerDetail },
setup() {
provide('drawer-open', true);
const appStore = useAppStore({});
appStore.state.drawerOpen = false;
},
template: `
<drawer-detail-group>

View File

@@ -3,6 +3,7 @@ import markdown from './readme.md';
import { defineComponent, provide, ref, watch } from '@vue/composition-api';
import withPadding from '../../../../../.storybook/decorators/with-padding';
import withAltColors from '../../../../../.storybook/decorators/with-alt-colors';
import useAppStore from '@/stores/app';
export default {
title: 'Views / Private / Components / Drawer Detail',
@@ -27,7 +28,9 @@ export const basic = () =>
},
setup(props) {
const open = ref(false);
provide('drawer-open', open);
const appStore = useAppStore();
appStore.state.drawerOpen = true;
watch(
() => props.drawerOpen,

View File

@@ -6,6 +6,7 @@ import VIcon from '@/components/v-icon';
import VDivider from '@/components/v-divider';
import TransitionExpand from '@/components/transition/expand';
import VBadge from '@/components/v-badge/';
import useAppStore from '@/stores/app';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
@@ -18,6 +19,9 @@ describe('Drawer Detail', () => {
it('Uses the useGroupable composition', () => {
jest.spyOn(GroupableComposable, 'useGroupable');
const appStore = useAppStore({});
appStore.state.drawerOpen = false;
mount(DrawerDetail, {
localVue,
propsData: {
@@ -25,7 +29,6 @@ describe('Drawer Detail', () => {
title: 'Users',
},
provide: {
'drawer-open': ref(false),
'item-group': {
register: () => {},
unregister: () => {},
@@ -39,6 +42,9 @@ describe('Drawer Detail', () => {
it('Passes the title prop as selection value', () => {
jest.spyOn(GroupableComposable, 'useGroupable');
const appStore = useAppStore({});
appStore.state.drawerOpen = false;
mount(DrawerDetail, {
localVue,
propsData: {
@@ -46,7 +52,6 @@ describe('Drawer Detail', () => {
title: 'Users',
},
provide: {
'drawer-open': ref(false),
'item-group': {
register: () => {},
unregister: () => {},

View File

@@ -3,7 +3,7 @@
<button class="toggle" @click="toggle" :class="{ open: active }">
<div class="icon">
<v-badge bordered :value="badge" :disabled="!badge">
<v-icon :name="icon" />
<v-icon :name="icon" outline />
</v-badge>
</div>
<div class="title" v-show="drawerOpen">
@@ -21,7 +21,8 @@
</template>
<script lang="ts">
import { defineComponent, ref, inject } from '@vue/composition-api';
import { defineComponent, toRefs } from '@vue/composition-api';
import useAppStore from '@/stores/app';
import { useGroupable } from '@/composables/groupable';
export default defineComponent({
@@ -41,7 +42,8 @@ export default defineComponent({
},
setup(props) {
const { active, toggle } = useGroupable(props.title, 'drawer-detail');
const drawerOpen = inject('drawer-open', ref(false));
const appStore = useAppStore();
const { drawerOpen } = toRefs(appStore.state);
return { active, toggle, drawerOpen };
},
});

View File

@@ -13,6 +13,7 @@
:width="file.width"
:height="file.height"
:title="file.title"
in-modal
@click="_active = false"
/>

View File

@@ -2,7 +2,7 @@
<div class="file-preview" v-if="type">
<div v-if="type === 'image'" class="image" :class="{ svg: isSVG }" @click="$emit('click')">
<img :src="src" :width="width" :height="height" :alt="title" />
<v-icon name="fullscreen" />
<v-icon v-if="inModal === false" name="fullscreen" />
</div>
<video v-else-if="type === 'video'" controls :src="src" />
@@ -36,6 +36,10 @@ export default defineComponent({
type: String,
required: true,
},
inModal: {
type: Boolean,
default: false,
},
},
setup(props) {
const type = computed<'image' | 'video' | 'audio' | null>(() => {
@@ -98,7 +102,7 @@ audio {
bottom: 12px;
z-index: 2;
color: white;
text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.5);
text-shadow: 0px 0px 8px rgba(0, 0, 0, 0.75);
opacity: 0;
transition: opacity var(--fast) var(--transition);
}

View File

@@ -105,31 +105,21 @@ export default defineComponent({
.title-container {
position: relative;
display: flex;
align-items: center;
max-width: 70%;
height: 100%;
margin-left: 12px;
overflow: hidden;
&.full {
margin-right: 20px;
padding-right: 20px;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(
90deg,
rgba(var(--background-page-rgb), 0) 0%,
rgba(var(--background-page-rgb), 1) 100%
);
content: '';
}
}
.headline {
position: absolute;
top: -20px;
top: 0;
left: 0;
opacity: 1;
transition: opacity var(--fast) var(--transition);
@@ -139,9 +129,12 @@ export default defineComponent({
position: relative;
display: flex;
align-items: center;
overflow: hidden;
h1 {
.type-title {
flex-grow: 1;
width: 100%;
overflow: hidden;
white-space: nowrap;
}
}

View File

@@ -31,25 +31,40 @@
</div>
<div class="toolbar">
<v-icon name="rotate_90_degrees_ccw" @click="rotate" v-tooltip.top="$t('rotate')" />
<div
v-tooltip.bottom.inverted="$t('drag_mode')"
class="drag-mode toolbar-button"
@click="dragMode = dragMode === 'crop' ? 'move' : 'crop'"
>
<v-icon name="pan_tool" :class="{ active: dragMode === 'move' }" />
<v-icon name="crop" :class="{ active: dragMode === 'crop' }" />
</div>
<v-icon
name="rotate_90_degrees_ccw"
@click="rotate"
v-tooltip.bottom.inverted="$t('rotate')"
/>
<v-icon
name="flip_horizontal"
@click="flip('horizontal')"
v-tooltip.top="$t('flip_horizontal')"
v-tooltip.bottom.inverted="$t('flip_horizontal')"
/>
<v-icon
name="flip_vertical"
@click="flip('vertical')"
v-tooltip.top="$t('flip_vertical')"
v-tooltip.bottom.inverted="$t('flip_vertical')"
/>
<v-menu
placement="top"
show-arrow
close-on-content-click
v-tooltip.top="$t('aspect_ratio')"
>
<v-menu placement="top" show-arrow close-on-content-click>
<template #activator="{ toggle }">
<v-icon :name="aspectRatioIcon" @click="toggle" />
<v-icon
:name="aspectRatioIcon"
@click="toggle"
v-tooltip.bottom.inverted="$t('aspect_ratio')"
/>
</template>
<v-list dense>
@@ -77,12 +92,41 @@
<v-list-item-icon><v-icon name="crop_free" /></v-list-item-icon>
<v-list-item-content>{{ $t('free') }}</v-list-item-content>
</v-list-item>
<v-list-item
v-if="imageData"
@click="aspectRatio = imageData.width / imageData.height"
:active="aspectRatio === imageData.width / imageData.height"
>
<v-list-item-icon><v-icon name="crop_original" /></v-list-item-icon>
<v-list-item-content>{{ $t('original') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<div class="spacer" />
<button class="toolbar-button cancel" v-show="cropping" @click="cropping = false">
{{ $t('cancel_crop') }}
</button>
</div>
</div>
<template #footer="{ close }">
<div class="dimensions" v-if="imageData">
<v-icon name="info_outline" />
{{ $n(imageData.width) }}x{{ $n(imageData.height) }}
<template
v-if="
(imageData.width !== newDimensions.width ||
imageData.height !== newDimensions.height)
"
>
->
{{ $n(newDimensions.width) }}x{{ $n(newDimensions.height) }}
</template>
</div>
<div class="spacer" />
<v-button @click="close" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="save" :loading="saving">{{ $t('save') }}</v-button>
</template>
@@ -90,11 +134,12 @@
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed } from '@vue/composition-api';
import { defineComponent, ref, watch, computed, reactive } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import Cropper from 'cropperjs';
import { nanoid } from 'nanoid';
import throttle from 'lodash/throttle';
type Image = {
type: string;
@@ -103,6 +148,8 @@ type Image = {
};
filesize: number;
filename_download: string;
width: number;
height: number;
};
export default defineComponent({
@@ -153,6 +200,9 @@ export default defineComponent({
rotate,
aspectRatio,
aspectRatioIcon,
newDimensions,
dragMode,
cropping,
} = useCropper();
watch(_active, (isActive) => {
@@ -187,6 +237,9 @@ export default defineComponent({
aspectRatioIcon,
saving,
imageURL,
newDimensions,
dragMode,
cropping,
};
function useImage() {
@@ -215,7 +268,14 @@ export default defineComponent({
loading.value = true;
const response = await api.get(`/${currentProjectKey}/files/${props.id}`, {
params: {
fields: ['data', 'type', 'filesize', 'filename_download'],
fields: [
'data',
'type',
'filesize',
'filename_download',
'width',
'height',
],
},
});
@@ -266,6 +326,18 @@ export default defineComponent({
const localAspectRatio = ref(NaN);
const newDimensions = reactive({
width: null as null | number,
height: null as null | number,
});
watch(imageData, () => {
if (!imageData.value) return;
localAspectRatio.value = imageData.value.width / imageData.value.height;
newDimensions.width = imageData.value.width;
newDimensions.height = imageData.value.height;
});
const aspectRatio = computed<number>({
get() {
return localAspectRatio.value;
@@ -277,6 +349,8 @@ export default defineComponent({
});
const aspectRatioIcon = computed(() => {
if (!imageData.value) return 'crop_original';
switch (aspectRatio.value) {
case 16 / 9:
return 'crop_16_9';
@@ -288,13 +362,51 @@ export default defineComponent({
return 'crop_7_5';
case 1 / 1:
return 'crop_square';
case imageData.value.width / imageData.value.height:
return 'crop_original';
case NaN:
default:
return 'crop_free';
}
});
return { cropperInstance, initCropper, flip, rotate, aspectRatio, aspectRatioIcon };
const localDragMode = ref<'move' | 'crop'>('move');
const dragMode = computed({
get() {
return localDragMode.value;
},
set(newMode: 'move' | 'crop') {
cropperInstance.value?.setDragMode(newMode);
localDragMode.value = newMode;
},
});
const localCropping = ref(false);
const cropping = computed({
get() {
return localCropping.value;
},
set(newCropping: boolean) {
if (newCropping === false) {
cropperInstance.value?.clear();
}
localCropping.value = newCropping;
},
});
return {
cropperInstance,
initCropper,
flip,
rotate,
aspectRatio,
aspectRatioIcon,
newDimensions,
dragMode,
cropping,
};
function initCropper() {
if (imageElement.value === null) return;
@@ -303,7 +415,35 @@ export default defineComponent({
cropperInstance.value.destroy();
}
cropperInstance.value = new Cropper(imageElement.value, { autoCrop: false });
if (!imageData.value) return;
cropperInstance.value = new Cropper(imageElement.value, {
autoCrop: false,
aspectRatio: imageData.value.width / imageData.value.height,
toggleDragModeOnDblclick: false,
dragMode: 'move',
crop: throttle((event) => {
if (!imageData.value) return;
if (
cropping.value === false &&
(event.detail.width || event.detail.height)
) {
cropping.value = true;
}
const newWidth = event.detail.width || imageData.value.width;
const newHeight = event.detail.height || imageData.value.height;
if (event.detail.rotate === 0 || event.detail.rotate === -180) {
newDimensions.width = Math.round(newWidth);
newDimensions.height = Math.round(newHeight);
} else {
newDimensions.height = Math.round(newWidth);
newDimensions.width = Math.round(newHeight);
}
}, 50),
});
}
function flip(type: 'horizontal' | 'vertical') {
@@ -367,19 +507,63 @@ export default defineComponent({
.toolbar {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 60px;
padding: 0 24px;
color: var(--white);
background-color: #263238;
> * {
.v-icon {
display: inline-block;
margin: 0 8px;
margin-right: 16px;
}
}
.spacer {
flex-grow: 1;
}
.dimensions {
color: var(--foreground-subdued);
font-feature-settings: 'tnum';
}
.warning {
color: var(--warning);
}
.toolbar-button {
padding: 8px;
background-color: rgba(255, 255, 255, 0.2);
border-radius: var(--border-radius);
cursor: pointer;
transition: background-color var(--fast) var(--transition);
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
.drag-mode {
margin-right: 16px;
margin-left: -8px;
.v-icon {
margin-right: 0;
opacity: 0.5;
&.active {
opacity: 1;
}
}
.v-icon:first-child {
margin-right: 8px;
}
}
.cancel {
padding-right: 16px;
padding-left: 16px;
}
</style>

View File

@@ -1,10 +1,12 @@
<template>
<v-modal v-model="_active" :title="$t('select_item')" no-padding>
<layout-tabular
class="layout"
<component
:is="`layout-${layout}`"
:collection="collection"
:selection="_selection"
:filters="filters"
:view-query.sync="query"
:view-options.sync="options"
@update:selection="onSelect"
select-mode
/>
@@ -17,8 +19,16 @@
</template>
<script lang="ts">
import { defineComponent, PropType, ref, computed } from '@vue/composition-api';
import {
defineComponent,
PropType,
ref,
computed,
toRefs,
onUnmounted,
} from '@vue/composition-api';
import { Filter } from '@/stores/collection-presets/types';
import useCollectionPreset from '@/composables/use-collection-preset';
export default defineComponent({
props: {
@@ -48,7 +58,17 @@ export default defineComponent({
const { _active } = useActiveState();
const { _selection, onSelect } = useSelection();
return { save, cancel, _active, _selection, onSelect };
const { collection } = toRefs(props);
const { viewType, viewOptions, viewQuery } = useCollectionPreset(collection);
// This is a local copy of the viewtype. This means that we can sync it the layout without
// having use-collection-preset auto-save the values
const layout = ref(viewType.value || 'tabular');
const options = ref(viewOptions.value);
const query = ref(viewQuery.value);
return { save, cancel, _active, _selection, onSelect, layout, options, query };
function useActiveState() {
const localActive = ref(false);
@@ -69,6 +89,10 @@ export default defineComponent({
function useSelection() {
const localSelection = ref<(string | number)[]>(null);
onUnmounted(() => {
localSelection.value = null;
});
const _selection = computed({
get() {
if (localSelection.value === null) {

View File

@@ -1,5 +1,5 @@
<template>
<v-modal v-model="_active" :title="$t('editing_in', { collection })" persistent>
<v-modal v-model="_active" :title="title" persistent form-width>
<v-form
:loading="loading"
:initial-values="item"
@@ -16,9 +16,11 @@
</template>
<script lang="ts">
import { defineComponent, ref, computed, PropType, watch } from '@vue/composition-api';
import { defineComponent, ref, computed, PropType, watch, toRefs } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import useCollection from '@/composables/use-collection';
import i18n from '@/lang';
export default defineComponent({
model: {
@@ -49,7 +51,19 @@ export default defineComponent({
const { _edits, loading, error, item } = useItem();
const { save, cancel } = useActions();
return { _active, _edits, loading, error, item, save, cancel };
const { collection } = toRefs(props);
const { info: collectionInfo } = useCollection(collection);
const title = computed(() => {
if (props.primaryKey === '+') {
return i18n.t('adding_in', { collection: collectionInfo.value?.name });
}
return i18n.t('editing_in', { collection: collectionInfo.value?.name });
});
return { _active, _edits, loading, error, item, save, cancel, title };
function useActiveState() {
const localActive = ref(false);

View File

@@ -85,7 +85,7 @@ export default defineComponent({
justify-content: flex-start;
width: 100%;
min-height: 64px;
margin-bottom: 4px;
margin-top: 4px;
padding: 12px;
color: var(--white);
border-radius: var(--border-radius);

View File

@@ -1,5 +1,5 @@
import readme from './readme.md';
import { defineComponent, ref, provide } from '@vue/composition-api';
import { defineComponent, ref } from '@vue/composition-api';
import NotificationsPreview from './notifications-preview.vue';
import NotificationItem from '../notification-item/';
import DrawerButton from '../drawer-button/';
@@ -8,6 +8,7 @@ import { NotificationRaw } from '@/stores/notifications/types';
import { i18n } from '@/lang';
import withPadding from '../../../../../.storybook/decorators/with-padding';
import VueRouter from 'vue-router';
import useAppStore from '@/stores/app';
export default {
title: 'Views / Private / Components / Notifications Preview',
@@ -52,7 +53,8 @@ export const basic = () =>
const notificationsStore = useNotificationsStore({});
const active = ref(false);
provide('drawer-open', ref(true));
const appStore = useAppStore({});
appStore.state.drawerOpen = true;
return { add, active };

View File

@@ -3,7 +3,11 @@
<transition-expand tag="div">
<div v-if="active" class="inline">
<div class="padding-box">
<router-link class="link" :to="activityLink">
<router-link
class="link"
:to="activityLink"
:class="{ 'has-items': lastFour.length > 0 }"
>
{{ $t('show_all_activity') }}
</router-link>
<transition-group tag="div" name="notification" class="transition">
@@ -19,7 +23,7 @@
<drawer-button
:active="active"
@click="$emit('toggle', !active)"
@click="active = !active"
v-tooltip.left="$t('notifications')"
class="toggle"
icon="notifications"
@@ -30,7 +34,7 @@
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import { defineComponent, computed, ref, watch } from '@vue/composition-api';
import DrawerButton from '../drawer-button';
import NotificationItem from '../notification-item';
import useNotificationsStore from '@/stores/notifications';
@@ -38,22 +42,28 @@ import useProjectsStore from '@/stores/projects';
export default defineComponent({
components: { DrawerButton, NotificationItem },
model: {
prop: 'active',
event: 'toggle',
},
props: {
active: {
drawerOpen: {
type: Boolean,
default: false,
},
},
setup() {
setup(props) {
const notificationsStore = useNotificationsStore();
const projectsStore = useProjectsStore();
const activityLink = computed(() => `/${projectsStore.state.currentProjectKey}/activity`);
const active = ref(false);
return { lastFour: notificationsStore.lastFour, activityLink };
watch(
() => props.drawerOpen,
(open: boolean) => {
if (open === false) {
active.value = false;
}
}
);
return { lastFour: notificationsStore.lastFour, activityLink, active };
},
});
</script>
@@ -72,6 +82,10 @@ export default defineComponent({
&:hover {
color: var(--foreground-normal);
}
&.has-items {
margin-bottom: 12px;
}
}
.transition {

View File

@@ -1,7 +1,7 @@
<template>
<value-null v-if="value === null || value === undefined" />
<span v-else-if="displayInfo === null">{{ value }}</span>
<span v-else-if="typeof displayInfo.handler === 'function'">
<span v-else-if="displayInfo === null" class="no-wrap">{{ value }}</span>
<span v-else-if="typeof displayInfo.handler === 'function'" class="no-wrap">
{{ displayInfo.handler(value, options, { type }) }}
</span>
<component
@@ -66,3 +66,12 @@ export default defineComponent({
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/no-wrap.scss';
.no-wrap {
line-height: 22px;
@include no-wrap;
}
</style>

View File

@@ -12,7 +12,7 @@
:type="part.type"
v-bind="part.options"
/>
<span v-else :key="index" class="raw">{{ part }}</span>
<span :key="index" v-else>{{ part + ' ' }}</span>
</template>
</div>
</template>
@@ -96,15 +96,22 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/no-wrap';
.render-template {
display: contents;
display: flex;
align-items: center;
max-width: 100%;
height: 100%;
& > * {
margin-right: 6px;
@include no-wrap;
}
}
.subdued {
color: var(--foreground-subdued);
}
.raw:not(:last-child) {
margin-right: 4px;
}
</style>

View File

@@ -107,6 +107,7 @@ export default defineComponent({
postComment,
saving,
getFormattedTime,
refresh,
};
function getFormattedTime(datetime: string) {

View File

@@ -4,6 +4,6 @@
<style lang="scss" scoped>
.null {
color: var(--foreground-subdued);
color: var(--border-normal); // Don't confuse NULL with subdued value
}
</style>

View File

@@ -32,22 +32,4 @@ describe('Views / Private', () => {
expect(component.find('.navigation').classes()).toEqual(['navigation', 'is-open']);
});
it('Adds the is-open class to the drawer', async () => {
const component = shallowMount(PrivateView, {
localVue,
i18n,
propsData: {
title: 'Title',
},
});
expect(component.find('.drawer').classes()).toEqual(['drawer', 'alt-colors']);
(component.vm as any).drawerOpen = true;
await component.vm.$nextTick();
expect(component.find('.drawer').classes()).toEqual(['drawer', 'alt-colors', 'is-open']);
});
});

Some files were not shown because too many files have changed in this diff Show More