Add the cards layout (#430)

* Fix reactivity of currentLayout in drawer detail

* Start on cards layout

* Use dense list items in v-select

* Add cards + size option

* Render cards + files based on options

* Allow modules to set view defaults

* Start on render-template component

* Add get fields from template util

* Use render template component in cards layout

* Render as small icon

* Accept options in display handler function

* Fix type warnings in format title display

* Remove empty styling in render template component

* Account for null values in render template

* Add loading state to cards layout

* Remove type validation in skeleton loader

* Only fetch rendered fields

* Fix resolving of default values for cards module

* Add selection state to cards

* Add selection state to cards

* Make render template reactive

* Implement setup options

* Add disabled support to v-select

* Add fallback icon option + disable fit input when no source

* Add sort header to cards layout

* Remove console log

* Add selection state to cards header

* Fix z-indexing of header menu

* Add pagination to cards layout

* Fix types in field

* Fix type checks in field-setup

* Add role presentation to img

* Remove code smell

* Handle file library gracefully

* Add native lazy loading to images in cards layout

* Render SVGs inline in card
This commit is contained in:
Rijk van Zanten
2020-04-18 16:20:00 -04:00
committed by GitHub
parent 57bb18b590
commit 3a89dc41f9
29 changed files with 1068 additions and 74 deletions

View File

@@ -98,3 +98,7 @@ Vue.component('drawer-detail', DrawerDetail);
import TransitionExpand from './transition/expand';
Vue.component('transition-expand', TransitionExpand);
import RenderTemplate from '@/views/private/components/render-template';
Vue.component('render-template', RenderTemplate);

View File

@@ -32,6 +32,7 @@ Renders a dropdown input.
| `placeholder` | What placeholder to show when no items are selected | |
| `full-width` | Render the select at full width | |
| `monospace` | Render the value and options monospaced | |
| `disabled` | Disable the select | |
## Events

View File

@@ -1,5 +1,10 @@
<template>
<v-menu class="v-select" attached :close-on-content-click="multiple === false">
<v-menu
:disabled="disabled"
class="v-select"
attached
:close-on-content-click="multiple === false"
>
<template #activator="{ toggle }">
<v-input
:full-width="fullWidth"
@@ -8,12 +13,13 @@
:value="displayValue"
@click="toggle"
:placeholder="placeholder"
:disabled="disabled"
>
<template #append><v-icon name="expand_more" /></template>
</v-input>
</template>
<v-list>
<v-list dense>
<v-list-item
v-for="item in _items"
:key="item.value"
@@ -23,9 +29,7 @@
@click="multiple ? null : $emit('input', item.value)"
>
<v-list-item-content>
<v-list-item-title v-if="multiple === false">
<span :class="{ monospace }">{{ item.text }}</span>
</v-list-item-title>
<span v-if="multiple === false" :class="{ monospace }">{{ item.text }}</span>
<v-checkbox
v-else
:inputValue="value || []"
@@ -41,6 +45,7 @@
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import i18n from '@/lang';
type Item = {
text: string;
@@ -85,10 +90,18 @@ export default defineComponent({
type: Boolean,
default: false,
},
allowNull: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props) {
const _items = computed(() =>
props.items.map((item) => {
const _items = computed(() => {
const items = props.items.map((item) => {
if (typeof item === 'string') {
return {
text: item,
@@ -100,8 +113,17 @@ export default defineComponent({
text: item[props.itemText],
value: item[props.itemValue],
};
})
);
});
if (props.allowNull) {
items.unshift({
text: i18n.t('none'),
value: null,
});
}
return items;
});
const displayValue = computed(() => {
if (Array.isArray(props.value)) {

View File

@@ -17,7 +17,6 @@ export default defineComponent({
type: {
type: String,
default: 'input',
validator: (type: string) => ['input', 'input-tall', 'list-item-icon'].includes(type),
},
},
});
@@ -78,6 +77,14 @@ export default defineComponent({
height: var(--input-height-tall);
}
.text {
flex-grow: 1;
height: 12px;
border-radius: 6px;
@include loader;
}
.list-item-icon {
display: flex;
align-items: center;

View File

@@ -18,7 +18,8 @@ export function useCollectionPreset(collection: Ref<string>) {
};
});
const viewOptions = computed({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewOptions = computed<Record<string, any>>({
get() {
return localPreset.value.view_options?.[localPreset.value.view_type] || null;
},
@@ -34,7 +35,8 @@ export function useCollectionPreset(collection: Ref<string>) {
},
});
const viewQuery = computed({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewQuery = computed<Record<string, any>>({
get() {
return localPreset.value.view_query?.[localPreset.value.view_type] || null;
},
@@ -50,9 +52,9 @@ export function useCollectionPreset(collection: Ref<string>) {
},
});
const viewType = computed({
const viewType = computed<string>({
get() {
return localPreset.value.view_type || 'tabular';
return localPreset.value.view_type || null;
},
set(val) {
localPreset.value = {

View File

@@ -6,6 +6,7 @@ import Vue from 'vue';
import { isEqual } from 'lodash';
import { Filter } from '@/stores/collection-presets/types';
import filtersToQuery from '@/utils/filters-to-query';
import { orderBy } from 'lodash';
type Options = {
limit: Ref<number>;
@@ -64,12 +65,13 @@ export function useItems(collection: Ref<string>, options: Options) {
return;
}
/**
* @NOTE
* Ignore sorting through the API when the full set of items is already loaded. This requires
* layouts to support client side sorting when the itemcount is less than the total items.
*/
if (limit.value > (itemCount.value || 0)) return;
// When all items are on page, we only sort locally
const hasAllItems = limit.value > (itemCount.value || 0);
if (hasAllItems) {
sortItems(after);
return;
}
await Vue.nextTick();
if (loading.value === false) {
@@ -102,7 +104,7 @@ export function useItems(collection: Ref<string>, options: Options) {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
const fieldsToFetch = [...fields.value];
let fieldsToFetch = [...fields.value];
// Make sure the primary key is always fetched
if (
@@ -122,6 +124,16 @@ export function useItems(collection: Ref<string>, options: Options) {
});
}
// Make sure we always fetch the image data for the directus_files collection when opening
// the file library
if (collection.value === 'directus_files' && fieldsToFetch.includes('data') === false) {
fieldsToFetch.push('data');
}
// Filter out fake internal columns. This is (among other things) for a fake $file m2o field
// on directus_files
fieldsToFetch = fieldsToFetch.filter((field) => field.startsWith('$') === false);
try {
const endpoint = collection.value.startsWith('directus_')
? `/${currentProjectKey}/${collection.value.substring(9)}`
@@ -137,7 +149,27 @@ export function useItems(collection: Ref<string>, options: Options) {
},
});
items.value = response.data.data;
let fetchedItems = response.data.data;
/**
* @NOTE
*
* This is used in conjunction with the fake field in /src/stores/fields/fields.ts to be
* able to render out the directus_files collection (file library) using regular layouts
*
* Layouts expect the file to be a m2o of a `file` type, however, directus_files is the
* only collection that doesn't have this (obviously). This fake $file field is used to
* pretend there is a file m2o, so we can use the regular layout logic for files as well
*/
if (collection.value === 'directus_files') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchedItems = fetchedItems.map((file: any) => ({
...file,
$file: file,
}));
}
items.value = fetchedItems;
if (itemCount.value === null) {
itemCount.value = response.data.data.length;
@@ -181,4 +213,10 @@ export function useItems(collection: Ref<string>, options: Options) {
items.value = [];
itemCount.value = null;
}
function sortItems(sortBy: string) {
const field = sortBy.startsWith('-') ? sortBy.substring(1) : sortBy;
const descending = sortBy.startsWith('-');
items.value = orderBy(items.value, [field], [descending ? 'desc' : 'asc']);
}
}

View File

@@ -18,7 +18,7 @@ export const basic = () =>
},
},
setup(props) {
const value = computed<string | null>(() => handler(props.val));
const value = computed<string | null>(() => handler(props.val, null));
return { value };
},
template: `

View File

@@ -5,12 +5,12 @@ jest.mock('@directus/format-title');
describe('Displays / Format Title', () => {
it('Runs the value through the title formatter', () => {
handler('test');
handler('test', null);
expect(formatTitle).toHaveBeenCalledWith('test');
});
it('Does not pass the value if the value is falsy', () => {
handler(null);
handler(null, null);
expect(formatTitle).not.toHaveBeenCalledWith(null);
});
});

View File

@@ -1,5 +1,5 @@
<template functional>
<v-icon :name="props.iconName" />
<v-icon small :name="props.iconName" />
</template>
<script lang="ts">

View File

@@ -3,7 +3,7 @@ import { Component } from 'vue';
import { Field } from '@/stores/fields/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type DisplayHandlerFunction = (value: any) => string | null;
export type DisplayHandlerFunction = (value: any, options: any) => string | null;
export type DisplayConfig = {
id: string;

View File

@@ -223,6 +223,12 @@
"upload_file_success": "File Uploaded | {count} Files Uploaded",
"upload_file_failed": "Couldn't Upload File | Couldn't Upload Files",
"view_type": "View As...",
"setup": "Setup",
"none": "None",
"n_items_selected": "No Items Selected | 1 Item Selected | {n} Items Selected",
"per_page": "Per Page",
"about_directus": "About Directus",
"activity_log": "Activity Log",
@@ -544,7 +550,6 @@
"no_related_entries": "Has no related entries",
"no_results": "No Results",
"no_results_body": "The current filters do not match any collection items",
"none": "None",
"not_authenticated": "Not Authenticated",
"not_between": "Not between",
"not_contains": "Doesn't contain",

View File

@@ -1,25 +1,15 @@
{
"layouts": {
"calendar": {
"calendar": "Calendar",
"fields": "Fields",
"today": "today",
"events": "Events",
"moreEvents": "and {amount} more...",
"noEvents": "no events yet!",
"date": "Date",
"datetime": "Datetime",
"time": "Time",
"title": "Title",
"color": "Color"
},
"cards": {
"cards": "Cards",
"size": "Size",
"image_source": "Image Source",
"image_fit": "Image Fit",
"crop": "Crop",
"contain": "Contain",
"title": "Title",
"subtitle": "Subtitle",
"src": "Image Source",
"content": "Body Content",
"fit": "Fit"
"fallback_icon": "Fallback Icon"
},
"tabular": {
"tabular": "Table",
@@ -29,18 +19,6 @@
"compact": "Compact",
"cozy": "Cozy",
"per_page": "Per Page"
},
"timeline": {
"timeline": "Timeline",
"fields": "Fields",
"today": "today",
"events": "Events",
"moreEvents": "and {amount} more...",
"noEvents": "no events yet!",
"date": "Date",
"content": "Content",
"title": "Title",
"color": "Color"
}
}
}

369
src/layouts/cards/cards.vue Normal file
View File

@@ -0,0 +1,369 @@
<template>
<div class="layout-cards" :style="{ '--size': size + 'px' }">
<portal to="drawer">
<drawer-detail icon="crop_square" :title="$t('layouts.cards.size')">
<v-slider v-model="size" :min="100" :max="200" :step="1" />
</drawer-detail>
<drawer-detail icon="format_list_numbered" :title="$t('per_page')">
<v-select
full-width
@input="limit = +$event"
:value="`${limit}`"
:items="['10', '25', '50', '100', '250']"
/>
</drawer-detail>
<drawer-detail icon="settings" :title="$t('setup')">
<div class="setting">
<div class="label type-text">{{ $t('layouts.cards.image_source') }}</div>
<v-select
v-model="imageSource"
full-width
allow-null
item-value="field"
item-text="name"
:items="fileFields"
/>
</div>
<div class="setting">
<div class="label type-text">{{ $t('layouts.cards.image_fit') }}</div>
<v-select
v-model="imageFit"
:disabled="imageSource === null"
full-width
:items="[
{
text: $t('layouts.cards.crop'),
value: 'crop',
},
{
text: $t('layouts.cards.contain'),
value: 'contain',
},
]"
/>
</div>
<div class="setting">
<div class="label type-text">{{ $t('layouts.cards.title') }}</div>
<v-input full-width v-model="title" />
</div>
<div class="setting">
<div class="label type-text">{{ $t('layouts.cards.subtitle') }}</div>
<v-input full-width v-model="subtitle" />
</div>
<div class="setting">
<div class="label type-text">{{ $t('layouts.cards.fallback_icon') }}</div>
<v-input full-width v-model="icon" />
</div>
</drawer-detail>
</portal>
<cards-header :fields="fieldsInCollection" :selection.sync="_selection" :sort.sync="sort" />
<div class="grid">
<template v-if="loading">
<card v-for="n in limit" :key="`loader-${n}`" loading />
</template>
<card
v-else
v-for="item in items"
:key="item[primaryKeyField.field]"
:crop="imageFit === 'crop'"
:icon="icon"
:file="imageSource ? item[imageSource] : null"
:item="item"
:select-mode="selectMode || _selection.length > 0"
:to="getLinkForItem(item)"
v-model="_selection"
>
<template #title v-if="title">
<render-template :collection="collection" :item="item" :template="title" />
</template>
<template #subtitle v-if="subtitle">
<render-template :collection="collection" :item="item" :template="subtitle" />
</template>
</card>
</div>
<div class="pagination" v-if="totalPages > 1">
<v-pagination
:length="totalPages"
:total-visible="5"
show-first-last
:value="page"
@input="toPage"
/>
</div>
<v-info
v-if="loading === false && items.length === 0"
:title="$tc('item_count', 0)"
icon="box"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, toRefs, inject, computed, ref } from '@vue/composition-api';
import { Filter } from '@/stores/collection-presets/types';
import useSync from '@/compositions/use-sync/';
import useCollection from '@/compositions/use-collection/';
import useItems from '@/compositions/use-items';
import Card from './components/card.vue';
import getFieldsFromTemplate from '@/utils/get-fields-from-template';
import { render } from 'micromustache';
import useProjectsStore from '@/stores/projects';
import CardsHeader from './components/header.vue';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Item = Record<string, any>;
type ViewOptions = {
size?: number;
icon?: string;
imageSource?: string;
title?: string;
subtitle?: string;
imageFit?: 'crop' | 'contain';
};
type ViewQuery = {
fields?: string[];
sort?: string;
limit?: number;
};
export default defineComponent({
components: { Card, CardsHeader },
props: {
collection: {
type: String,
required: true,
},
selection: {
type: Array as PropType<Item[]>,
default: undefined,
},
viewOptions: {
type: Object as PropType<ViewOptions>,
default: null,
},
viewQuery: {
type: Object as PropType<ViewQuery>,
default: null,
},
filters: {
type: Array as PropType<Filter[]>,
default: () => [],
},
selectMode: {
type: Boolean,
default: false,
},
detailRoute: {
type: String,
default: `/{{project}}/collections/{{collection}}/{{primaryKey}}`,
},
file: {
type: Object as PropType<File>,
default: null,
},
},
setup(props, { emit }) {
const mainElement = inject('main-element', ref<Element>(null));
const projectsStore = useProjectsStore();
const _selection = useSync(props, 'selection', emit);
const _viewOptions = useSync(props, 'viewOptions', emit);
const _viewQuery = useSync(props, 'viewQuery', emit);
const _filters = useSync(props, 'filters', emit);
const { collection } = toRefs(props);
const { primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
const availableFields = computed(() =>
fieldsInCollection.value.filter(({ hidden_browse }) => hidden_browse === false)
);
const fileFields = computed(() => {
return [...availableFields.value.filter((field) => field.type === 'file')];
});
const { size, icon, imageSource, title, subtitle, imageFit } = useViewOptions();
const { sort, limit, page, fields } = useViewQuery();
const { items, loading, error, totalPages, itemCount } = useItems(collection, {
sort,
limit,
page,
fields: fields,
filters: _filters,
});
return {
_selection,
items,
loading,
error,
totalPages,
page,
toPage,
itemCount,
availableFields,
limit,
size,
primaryKeyField,
icon,
fileFields,
imageSource,
title,
subtitle,
getLinkForItem,
imageFit,
sort,
fieldsInCollection,
};
function toPage(newPage: number) {
page.value = newPage;
mainElement.value?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
function useViewOptions() {
const size = createViewOption('size', 120);
const icon = createViewOption('icon', 'box');
const title = createViewOption<string>('title', null);
const subtitle = createViewOption<string>('subtitle', null);
const imageSource = createViewOption<string>(
'imageSource',
fileFields.value[0]?.field ?? null
);
const imageFit = createViewOption<string>('imageFit', 'crop');
return { size, icon, imageSource, title, subtitle, imageFit };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createViewOption<T>(key: keyof ViewOptions, defaultValue: any) {
return computed<T>({
get() {
return _viewOptions.value?.[key] !== undefined
? _viewOptions.value?.[key]
: defaultValue;
},
set(newValue: T) {
_viewOptions.value = {
..._viewOptions.value,
[key]: newValue,
};
},
});
}
}
function useViewQuery() {
const page = ref(1);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sort = createViewQueryOption<string>('sort', primaryKeyField.value!.field);
const limit = createViewQueryOption<number>('limit', 25);
const fields = computed<string[]>(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fields = [primaryKeyField.value!.field];
if (imageSource.value) {
fields.push(`${imageSource.value}.type`);
fields.push(`${imageSource.value}.data`);
}
if (title.value) {
fields.push(...getFieldsFromTemplate(title.value));
}
if (subtitle.value) {
fields.push(...getFieldsFromTemplate(subtitle.value));
}
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
if (fields.includes(sortField) === false) {
fields.push(sortField);
}
return fields;
});
return { sort, limit, page, fields };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createViewQueryOption<T>(key: keyof ViewQuery, defaultValue: any) {
return computed<T>({
get() {
return _viewQuery.value?.[key] || defaultValue;
},
set(newValue: T) {
page.value = 1;
_viewQuery.value = {
..._viewQuery.value,
[key]: newValue,
};
},
});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getLinkForItem(item: Record<string, any>) {
const currentProjectKey = projectsStore.state.currentProjectKey;
return render(props.detailRoute, {
item: item,
collection: props.collection,
project: currentProjectKey,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
primaryKey: item[primaryKeyField.value!.field],
});
}
},
});
</script>
<style lang="scss" scoped>
.layout-cards {
padding: var(--content-padding);
padding-top: 0;
}
.grid {
display: grid;
grid-gap: calc(0.1666666667 * var(--size)) 24px;
grid-template-columns: repeat(auto-fit, var(--size));
}
.v-info {
margin: 20vh 0;
}
.label {
margin-bottom: 4px;
}
.setting {
margin-bottom: 12px;
}
.pagination {
position: sticky;
left: 0;
width: 100%;
padding: 32px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<div class="card" :class="{ loading }" @click="handleClick">
<div class="header" :class="{ selected: value.includes(item) }">
<div class="selection-indicator" :class="{ 'select-mode': selectMode }">
<v-icon :name="selectionIcon" @click.stop="toggleSelection" />
</div>
<v-skeleton-loader v-if="loading" />
<template v-else>
<p v-if="type" class="type type-title">{{ type }}</p>
<template v-else>
<img
class="image"
loading="lazy"
v-if="imageSource"
:src="imageSource"
alt=""
role="presentation"
/>
<img
class="svg"
v-else-if="svgSource"
:src="svgSource"
alt=""
role="presentation"
/>
<v-icon v-else large :name="icon" />
</template>
</template>
</div>
<v-skeleton-loader v-if="loading" type="text" />
<template v-else>
<div class="title" v-if="$slots.title"><slot name="title" /></div>
<div class="subtitle" v-if="$slots.subtitle"><slot name="subtitle" /></div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import router from '@/router';
type File = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
type: string;
data: {
full_url: string;
thumbnails: {
key: string;
url: string;
}[];
};
};
export default defineComponent({
props: {
icon: {
type: String,
default: 'box',
},
file: {
type: Object as PropType<File>,
default: null,
},
crop: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
item: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Object as PropType<Record<string, any>>,
default: null,
},
value: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Array as PropType<Record<string, any>[]>,
default: () => [],
},
selectMode: {
type: Boolean,
default: false,
},
to: {
type: String,
default: '',
},
},
setup(props, { emit }) {
const type = computed(() => {
if (props.file === null) return null;
if (props.file.type.startsWith('image')) return null;
return props.file.type.split('/')[1];
});
const imageSource = computed(() => {
if (props.file === null) return null;
if (props.file.type.startsWith('image') === false) return null;
if (props.file.type.includes('svg')) return null;
let key = 'directus-medium-crop';
if (props.crop === false) {
key = 'directus-medium-contain';
}
const thumbnail = props.file.data.thumbnails.find((thumbnail) => thumbnail.key === key);
if (!thumbnail) return null;
return thumbnail.url;
});
const svgSource = computed(() => {
if (props.file === null) return null;
if (props.file.type.startsWith('image') === false) return null;
if (props.file.type.includes('svg') === false) return null;
return props.file.data.full_url;
});
const selectionIcon = computed(() =>
props.value.includes(props.item) ? 'check_circle' : 'radio_button_unchecked'
);
return { imageSource, svgSource, type, selectionIcon, toggleSelection, handleClick };
function toggleSelection() {
if (props.value.includes(props.item)) {
emit(
'input',
props.value.filter((item) => item !== props.item)
);
} else {
emit('input', [...props.value, props.item]);
}
}
function handleClick() {
if (props.selectMode === true) {
toggleSelection();
} else {
router.push(props.to);
}
}
},
});
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
}
.header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: var(--size);
height: var(--size);
overflow: hidden;
background-color: var(--background-normal);
border-radius: var(--border-radius);
&.selected {
background-color: var(--background-normal-alt);
}
.image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.svg {
width: 50%;
height: 50%;
object-fit: contain;
}
.type {
color: var(--foreground-subdued);
text-transform: uppercase;
}
.v-icon {
--v-icon-color: var(--foreground-subdued);
}
::v-deep .v-skeleton-loader {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.selection-indicator {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 30%;
opacity: 0;
transition: opacity var(--fast) var(--transition);
&:hover,
&.select-mode {
opacity: 1;
}
.v-icon {
--v-icon-color: var(--white);
margin: 5%;
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(-180deg, #263238 10%, rgba(38, 50, 56, 0));
opacity: 0.3;
content: '';
}
}
}
.loading {
.header {
margin-bottom: 8px;
}
}
.title,
.subtitle {
width: 100%;
overflow: hidden;
line-height: 1.3em;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
margin-top: 4px;
}
.subtitle {
color: var(--foreground-subdued);
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="cards-header">
<div class="start">
<div class="selected" v-if="_selection.length > 0">
<v-icon name="close" @click="_selection = []" />
{{ $tc('n_items_selected', _selection.length) }}
</div>
</div>
<div class="end">
<v-menu show-arrow placement="bottom">
<template #activator="{ toggle }">
<div class="sort-selector" @click="toggle">
{{ sortField && sortField.name }}
</div>
</template>
<v-list dense>
<v-list-item
v-for="field in fields"
:key="field.field"
:active="field.field === sortKey"
@click="_sort = field.field"
>
<v-list-item-content>{{ field.name }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<v-icon
class="sort-direction"
:class="{ descending }"
name="sort"
@click="toggleDescending"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import useSync from '@/compositions/use-sync';
export default defineComponent({
props: {
fields: {
type: Array as PropType<Field[]>,
required: true,
},
sort: {
type: String,
required: true,
},
selection: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Array as PropType<Record<string, any>>,
default: () => [],
},
},
setup(props, { emit }) {
const _sort = useSync(props, 'sort', emit);
const _selection = useSync(props, 'selection', emit);
const descending = computed(() => props.sort.startsWith('-'));
const sortKey = computed(() =>
props.sort.startsWith('-') ? props.sort.substring(1) : props.sort
);
const sortField = computed(() => {
return props.fields.find((field) => field.field === sortKey.value);
});
return { descending, toggleDescending, sortField, _sort, _selection, sortKey };
function toggleDescending() {
if (descending.value === true) {
_sort.value = _sort.value.substring(1);
} else {
_sort.value = '-' + _sort.value;
}
}
},
});
</script>
<style lang="scss" scoped>
.cards-header {
position: sticky;
top: var(--layout-offset-top);
z-index: 4;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 52px;
margin-bottom: 36px;
padding: 0 8px;
background-color: var(--background-page);
border-top: 2px solid var(--border-subdued);
border-bottom: 2px solid var(--border-subdued);
box-shadow: 0 0 0 2px var(--background-page);
}
.end {
display: flex;
align-items: center;
color: var(--foreground-subdued);
.sort-selector {
margin-right: 8px;
}
.sort-selector:hover {
color: var(--foreground-normal);
cursor: pointer;
}
.sort-direction.descending {
transform: scaleY(-1);
}
.sort-direction:hover {
--v-icon-color: var(--foreground-normal);
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,9 @@
import { defineLayout } from '@/layouts/define';
import CardsLayout from './cards.vue';
export default defineLayout(({ i18n }) => ({
id: 'cards',
name: i18n.t('layouts.cards.cards'),
icon: 'view_module',
component: CardsLayout,
}));

View File

@@ -1,3 +1,5 @@
import TabularLayout from './tabular/';
export const layouts = [TabularLayout];
import CardsLayout from './cards/';
export const layouts = [TabularLayout, CardsLayout];
export default layouts;

View File

@@ -73,6 +73,7 @@
:headers.sync="tableHeaders"
:row-height="tableRowHeight"
:server-sort="itemCount === limit || totalPages > 1"
:item-key="primaryKeyField.field"
@click:row="onRowClick"
@update:sort="onSortChange"
>
@@ -116,7 +117,7 @@ type ViewOptions = {
spacing?: 'comfortable' | 'cozy' | 'compact';
};
export type ViewQuery = {
type ViewQuery = {
fields?: string[];
sort?: string;
};
@@ -211,6 +212,7 @@ export default defineComponent({
limit,
activeFields,
tableSpacing,
primaryKeyField,
};
function toPage(newPage: number) {

View File

@@ -313,7 +313,7 @@ export default defineComponent({
get() {
return edits.value.interface || props.existingField?.interface;
},
set(newInterface: string) {
set(newInterface: string | null) {
edits.value = {
...edits.value,
interface: newInterface,
@@ -342,7 +342,7 @@ export default defineComponent({
get() {
return edits.value.display || props.existingField?.display;
},
set(newDisplay: string) {
set(newDisplay: string | null) {
edits.value = {
...edits.value,
display: newDisplay,

View File

@@ -133,6 +133,20 @@ export default defineComponent({
];
});
if (viewType.value === null) {
viewType.value = 'cards';
}
if (viewOptions.value === null) {
if ((viewType.value = 'cards')) {
viewOptions.value = {
icon: 'person',
title: '{{first_name}} {{last_name}}',
subtitle: '{{ title }}',
};
}
}
return {
_filters,
addNewLink,

View File

@@ -6,7 +6,7 @@ const defaultCollectionPreset: Omit<CollectionPreset, 'collection'> = {
user: null,
search_query: null,
filters: null,
view_type: 'tabular',
view_type: null,
view_query: null,
view_options: null,
};

View File

@@ -35,7 +35,7 @@ export type CollectionPreset = {
collection: string;
search_query: null;
filters: Filter[] | null;
view_type: string;
view_type: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
view_query: { [view_type: string]: any } | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -10,6 +10,36 @@ import notify from '@/utils/notify';
import useRelationsStore from '@/stores/relations';
import { Relation } from '@/stores/relations/types';
const fakeFilesField: Field = {
id: -1,
collection: 'directus_files',
field: '$file',
name: i18n.t('file'),
datatype: null,
type: 'file',
unique: false,
primary_key: false,
default_value: null,
auto_increment: false,
note: null,
signed: false,
sort: 0,
interface: null,
options: null,
display: 'file',
display_options: null,
hidden_detail: true,
hidden_browse: false,
locked: true,
required: false,
translation: null,
readonly: true,
width: 'full',
validation: null,
group: null,
length: null,
};
export const useFieldsStore = createStore({
id: 'fieldsStore',
state: () => ({
@@ -38,7 +68,17 @@ export const useFieldsStore = createStore({
const settingsResponse = await api.get(`/${currentProjectKey}/settings/fields`);
fields.push(...settingsResponse.data.data);
this.state.fields = fields.map(this.addTranslationsForField);
/**
* @NOTE
*
* directus_fields is another special case. For it to play nice with layouts, we need to
* treat the actual image as a separate available field, instead of part of the regular
* item (normally all file related info is nested within a separate column). This allows
* layouts to render out files as it if were a "normal" collection, where the actual file
* is a fake m2o to itself.
*/
this.state.fields = [...fields.map(this.addTranslationsForField), fakeFilesField];
},
async dehydrate() {
this.reset();

View File

@@ -11,18 +11,18 @@ export interface FieldRaw {
id: number;
collection: string;
field: string;
datatype: string;
datatype: string | null;
unique: boolean;
primary_key: boolean;
auto_increment: boolean;
default_value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
note: string;
note: string | null;
signed: boolean;
type: string;
sort: null | number;
interface: string;
interface: string | null;
options: null | { [key: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
display: string;
display: string | null;
display_options: null | { [key: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any
hidden_detail: boolean;
hidden_browse: boolean;
@@ -33,7 +33,7 @@ export interface FieldRaw {
width: null | Width;
validation: string | null;
group: number | null;
length: string | number;
length: string | number | null;
}
export interface Field extends FieldRaw {

View File

@@ -0,0 +1,13 @@
export default function getFieldsFromTemplate(string: string) {
const regex = /{{(.*?)}}/g;
let fields = string.match(regex);
if (!Array.isArray(fields)) {
return [];
}
fields = fields.map((field) => {
return field.replace(/{{/g, '').replace(/}}/g, '').trim();
});
return fields;
}

View File

@@ -0,0 +1,4 @@
import getFieldsFromTemplate from './get-fields-from-template';
export { getFieldsFromTemplate };
export default getFieldsFromTemplate;

View File

@@ -16,13 +16,15 @@ export default defineComponent({
},
},
setup(props, { emit }) {
let currentLayout = layouts.find((layout) => layout.id === props.value);
const currentLayout = computed(() => {
const layout = layouts.find((layout) => layout.id === props.value);
// If for whatever reason the current layout doesn't exist, force reset it to tabular
if (currentLayout === undefined) {
currentLayout = layouts.find((layout) => layout.id === 'tabular');
emit('input', 'tabular');
}
if (layout === undefined) {
return layouts.find((layout) => layout.id === 'tabular');
}
return layout;
});
const viewType = computed({
get() {

View File

@@ -0,0 +1,4 @@
import RenderTemplate from './render-template.vue';
export { RenderTemplate };
export default RenderTemplate;

View File

@@ -0,0 +1,90 @@
<template>
<div class="render-template">
<template v-for="(part, index) in parts">
<component
v-if="part !== null && typeof part === 'object'"
:is="`display-${part.component}`"
:key="index"
:value="part.value"
v-bind="part.options"
/>
<template v-else>{{ part }}</template>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import useFieldsStore from '@/stores/fields';
import { get } from 'lodash';
import { Field } from '@/stores/fields/types';
import displays from '@/displays';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
item: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Object as PropType<Record<string, any>>,
required: true,
},
template: {
type: String,
required: true,
},
},
setup(props) {
const fieldsStore = useFieldsStore();
const regex = /({{.*?}})/g;
const parts = computed(() =>
props.template.split(regex).map((part) => {
if (part.startsWith('{{') === false) return part;
const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
// Instead of crashing when the field doesn't exist, we'll render a couple question
// marks to indicate it's absence
if (!field) return '???';
// Try getting the value from the item, return some question marks if it doesn't exist
const value = get(props.item, fieldKey);
if (value === undefined) return '???';
// If no display is configured, we can render the raw value
if (field.display === null) return value;
const displayInfo = displays.find((display) => display.id === field.display);
// If used display doesn't exist in the current project, return raw value
if (!displayInfo) return value;
// If the display handler is a function, we parse the value and return the result
if (typeof displayInfo.handler === 'function') {
const handler = displayInfo.handler as Function;
return handler(value, field.display_options);
}
return {
component: field.display,
options: field.display_options,
value: value,
};
})
);
return { parts };
},
});
</script>
<style lang="scss" scoped>
.render-template {
display: contents;
}
</style>