mirror of
https://github.com/directus/directus.git
synced 2026-01-28 12:58:23 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template functional>
|
||||
<v-icon :name="props.iconName" />
|
||||
<v-icon small :name="props.iconName" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
369
src/layouts/cards/cards.vue
Normal 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>
|
||||
261
src/layouts/cards/components/card.vue
Normal file
261
src/layouts/cards/components/card.vue
Normal 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>
|
||||
127
src/layouts/cards/components/header.vue
Normal file
127
src/layouts/cards/components/header.vue
Normal 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>
|
||||
9
src/layouts/cards/index.ts
Normal file
9
src/layouts/cards/index.ts
Normal 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,
|
||||
}));
|
||||
@@ -1,3 +1,5 @@
|
||||
import TabularLayout from './tabular/';
|
||||
export const layouts = [TabularLayout];
|
||||
import CardsLayout from './cards/';
|
||||
|
||||
export const layouts = [TabularLayout, CardsLayout];
|
||||
export default layouts;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
4
src/utils/get-fields-from-template/index.ts
Normal file
4
src/utils/get-fields-from-template/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import getFieldsFromTemplate from './get-fields-from-template';
|
||||
|
||||
export { getFieldsFromTemplate };
|
||||
export default getFieldsFromTemplate;
|
||||
@@ -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() {
|
||||
|
||||
4
src/views/private/components/render-template/index.ts
Normal file
4
src/views/private/components/render-template/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import RenderTemplate from './render-template.vue';
|
||||
|
||||
export { RenderTemplate };
|
||||
export default RenderTemplate;
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user