Add Tree-View Interface (#4602)

* Fix local type extraction

* Render basic tree in tree-view

* Render drawer-item in tree-view-group

* Retrieve nested draggable changes

* Default sort query to configured sortField

* Store nested group / sort to API

* Allow updating item values

* Figure out a dropzone approach on preview

* Finish editable tree view

* Set sort value based on sort field in relation

* Add create-new / add-existing

* Respect previously made nested edits

* Add description in setup

* Fix fetching level of o2m sort field

* Remove min height on empty root

* Remove unused types

* Add notice for invalid relationship type

* Allow recursive o2m in setup

* Styling tweak

* Revert changes in v-list

* Revert changes in groupable
This commit is contained in:
Rijk van Zanten
2021-03-26 17:12:55 -04:00
committed by GitHub
parent a12c433249
commit b562ceeb0c
12 changed files with 666 additions and 17 deletions

View File

@@ -155,8 +155,6 @@ function getDBQuery(
delete queryCopy.limit;
}
query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }];
applyQuery(table, dbQuery, queryCopy, schema);
return dbQuery;
@@ -187,6 +185,10 @@ function applyParentFilters(nestedCollectionNodes: NestedCollectionNode[], paren
nestedNode.children.push({ type: 'field', name: nestedNode.relation.many_field });
}
if (nestedNode.relation.sort_field) {
nestedNode.children.push({ type: 'field', name: nestedNode.relation.sort_field });
}
nestedNode.query = {
...nestedNode.query,
filter: {
@@ -245,16 +247,30 @@ function mergeWithParentItems(
}
} else if (nestedNode.type === 'o2m') {
for (const parentItem of parentItems) {
let itemChildren = nestedItems.filter((nestedItem) => {
if (nestedItem === null) return false;
if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true;
let itemChildren = nestedItems
.filter((nestedItem) => {
if (nestedItem === null) return false;
if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true;
return (
nestedItem[nestedNode.relation.many_field] == parentItem[nestedNode.relation.one_primary!] ||
nestedItem[nestedNode.relation.many_field]?.[nestedNode.relation.one_primary!] ==
parentItem[nestedNode.relation.one_primary!]
);
});
return (
nestedItem[nestedNode.relation.many_field] == parentItem[nestedNode.relation.one_primary!] ||
nestedItem[nestedNode.relation.many_field]?.[nestedNode.relation.one_primary!] ==
parentItem[nestedNode.relation.one_primary!]
);
})
.sort((a, b) => {
// This is pre-filled in get-ast-from-query
const { column, order } = nestedNode.query.sort![0]!;
if (a[column] === b[column]) return 0;
if (a[column] === null) return 1;
if (b[column] === null) return -1;
if (order === 'asc') {
return a[column] < b[column] ? -1 : 1;
} else {
return a[column] < b[column] ? 1 : -1;
}
});
// We re-apply the requested limit here. This forces the _n_ nested items per parent concept
if (nested) {

View File

@@ -445,7 +445,9 @@ export class PayloadService {
const relatedRecords: Partial<Item>[] = [];
if (Array.isArray(payload[relation.one_field!])) {
for (const relatedRecord of payload[relation.one_field!] || []) {
for (let i = 0; i < (payload[relation.one_field!] || []).length; i++) {
const relatedRecord = (payload[relation.one_field!] || [])[i];
let record = cloneDeep(relatedRecord);
if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') {
@@ -467,6 +469,15 @@ export class PayloadService {
};
}
if (relation.sort_field) {
record = {
...record,
[relation.sort_field]: i + 1,
};
}
console.log(record);
relatedRecords.push({
...record,
[relation.many_field]: parent || payload[relation.one_primary!],

View File

@@ -5,6 +5,10 @@ import { Permission } from './permissions';
export type SchemaOverview = {
tables: SO;
relations: Relation[];
collections: {
collection: string;
sort_field: string | null;
}[];
fields: {
id: number;
collection: string;

View File

@@ -61,6 +61,11 @@ export default async function getASTFromQuery(
delete query.fields;
delete query.deep;
if (!query.sort) {
const sortField = schema.collections.find((collectionInfo) => collectionInfo.collection === collection)?.sort_field;
query.sort = [{ column: sortField || schema.tables[collection].primary, order: 'asc' }];
}
ast.children = await parseFields(collection, fields, deep);
return ast;
@@ -180,6 +185,10 @@ export default async function getASTFromQuery(
query: getDeepQuery(deep?.[relationalField] || {}),
children: await parseFields(relatedCollection, nestedFields as string[], deep?.[relationalField] || {}),
};
if (relationType === 'o2m' && !child!.query.sort) {
child!.query.sort = [{ column: relation.sort_field || relation.many_primary, order: 'asc' }];
}
}
if (child) {

View File

@@ -24,6 +24,10 @@ export async function getSchema(options?: {
const relations = await database.select('*').from('directus_relations');
const collections = await database
.select<{ collection: string; sort_field: string | null }[]>('collection', 'sort_field')
.from('directus_collections');
const fields = await database
.select<{ id: number; collection: string; field: string; special: string }[]>(
'id',
@@ -72,6 +76,7 @@ export async function getSchema(options?: {
return {
tables: schemaOverview,
relations: relations,
collections,
fields: fields.map((transform) => ({
...transform,
special: transform.special?.split(','),

View File

@@ -0,0 +1,14 @@
import { defineInterface } from '../define';
import InterfaceTreeView from './tree-view.vue';
export default defineInterface(({ i18n }) => ({
id: 'tree-view',
name: i18n.t('tree_view'),
description: i18n.t('interfaces.tree-view.description'),
icon: 'account_tree',
types: ['alias'],
groups: ['o2m'],
relational: true,
component: InterfaceTreeView,
options: [],
}));

View File

@@ -0,0 +1,72 @@
<template>
<div class="preview">
<render-template :collection="collection" :template="template" :item="item" />
<div class="spacer" />
<div class="actions" v-if="!disabled">
<v-icon v-tooltip="$t('edit')" name="launch" @click="editActive = true" />
<v-icon v-tooltip="$t('deselect')" name="clear" @click="$emit('deselect')" />
</div>
<drawer-item
:active.sync="editActive"
:collection="collection"
:primary-key="item[primaryKeyField] || '+'"
:edits="item"
@input="$emit('input', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import DrawerItem from '@/views/private/components/drawer-item';
export default defineComponent({
components: { DrawerItem },
props: {
collection: {
type: String,
required: true,
},
template: {
type: String,
required: true,
},
item: {
type: Object,
required: true,
},
primaryKeyField: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const editActive = ref(false);
return { editActive };
},
});
</script>
<style lang="scss" scoped>
.preview {
display: flex;
.spacer {
flex-grow: 1;
}
.actions {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
.v-icon + .v-icon {
margin-left: 4px;
}
}
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<draggable
class="drag-area"
:class="{ root, drag }"
tag="ul"
:list="tree"
:group="{ name: 'g1' }"
item-key="id"
draggable=".row"
v-bind="dragOptions"
@start="drag = true"
@end="drag = false"
:set-data="hideDragImage"
:disabled="disabled"
@change="$emit('change', $event)"
>
<li class="row" v-for="(item, index) in tree" :key="item.id">
<item-preview
:item="item"
:template="template"
:collection="collection"
:primary-key-field="primaryKeyField"
:disabled="disabled"
@input="replaceItem(index, $event)"
@deselect="removeItem(index)"
/>
<nested-draggable
:tree="item[childrenField] || []"
:template="template"
:collection="collection"
:primary-key-field="primaryKeyField"
:children-field="childrenField"
:disabled="disabled"
@change="$emit('change', $event)"
@input="replaceChildren(index, $event)"
/>
</li>
</draggable>
</template>
<script lang="ts">
import draggable from 'vuedraggable';
import { defineComponent, ref, PropType } from '@vue/composition-api';
import hideDragImage from '@/utils/hide-drag-image';
import ItemPreview from './item-preview.vue';
export default defineComponent({
name: 'nested-draggable',
props: {
tree: {
required: true,
type: Array as PropType<Record<string, any>[]>,
default: () => [],
},
root: {
type: Boolean,
default: false,
},
collection: {
type: String,
required: true,
},
template: {
type: String,
required: true,
},
primaryKeyField: {
type: String,
required: true,
},
childrenField: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
components: {
draggable,
ItemPreview,
},
setup(props, { emit }) {
const drag = ref(false);
const editActive = ref(false);
return {
drag,
hideDragImage,
editActive,
dragOptions: {
animation: 150,
group: 'description',
disabled: false,
ghostClass: 'ghost',
},
replaceItem,
removeItem,
replaceChildren,
};
function replaceItem(index: number, item: Record<string, any>) {
emit(
'input',
props.tree.map((child, childIndex) => {
if (childIndex === index) {
return item;
}
return child;
})
);
}
function removeItem(index: number) {
emit(
'input',
props.tree.filter((child, childIndex) => childIndex !== index)
);
}
function replaceChildren(index: number, tree: Record<string, any>[]) {
emit(
'input',
props.tree.map((child, childIndex) => {
if (childIndex === index) {
return {
...child,
[props.childrenField]: tree,
};
}
return child;
})
);
}
},
});
</script>
<style lang="scss" scoped>
.drag-area {
min-height: 12px;
&.root {
margin-left: 0;
padding: 0;
&:empty {
min-height: 0;
}
}
}
.row {
.preview {
padding: 12px 12px;
background-color: var(--card-face-color);
border-radius: var(--border-radius);
box-shadow: 0px 0px 6px 0px rgba(var(--card-shadow-color), 0.2);
cursor: grab;
transition: var(--fast) var(--transition);
transition-property: box-shadow, background-color;
& + .drag-area:not(:empty) {
padding-top: 12px;
}
}
}
.flip-list-move {
transition: transform 0.5s;
}
.ghost .preview {
background-color: var(--primary-alt);
box-shadow: 0 !important;
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<v-notice type="warning" v-if="relation.many_collection !== relation.one_collection">
{{ $t('interfaces.tree-view.recursive_only') }}
</v-notice>
<div v-else class="tree-view">
<nested-draggable
:template="template"
:collection="collection"
:tree="stagedValues || []"
:primary-key-field="primaryKeyField.field"
:children-field="relation.one_field"
:disabled="disabled"
root
@change="onDraggableChange"
@input="emitValue"
/>
<div class="actions" v-if="!disabled">
<v-button class="new" @click="addNewActive = true">{{ $t('create_new') }}</v-button>
<v-button class="existing" @click="selectDrawer = true">
{{ $t('add_existing') }}
</v-button>
</div>
<drawer-item
v-if="!disabled"
:active="addNewActive"
:collection="collection"
:primary-key="'+'"
:edits="{}"
@input="addNew"
@update:active="addNewActive = false"
/>
<drawer-collection
v-if="!disabled"
:active.sync="selectDrawer"
:collection="collection"
:selection="[]"
:filters="selectionFilters"
@input="stageSelection"
multiple
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, PropType, onMounted, watch } from '@vue/composition-api';
import { useCollection } from '@/composables/use-collection';
import { useRelationsStore } from '@/stores';
import api from '@/api';
import getFieldsFromTemplate from '@/utils/get-fields-from-template';
import draggable from 'vuedraggable';
import hideDragImage from '@/utils/hide-drag-image';
import NestedDraggable from './nested-draggable.vue';
import { Filter } from '@/types';
import { Relation } from '@/types';
import DrawerCollection from '@/views/private/components/drawer-collection';
import DrawerItem from '@/views/private/components/drawer-item';
export default defineComponent({
components: { draggable, NestedDraggable, DrawerCollection, DrawerItem },
props: {
value: {
type: Array as PropType<(number | string | Record<string, any>)[]>,
default: null,
},
displayTemplate: {
type: String,
default: undefined,
},
disabled: {
type: Boolean,
default: false,
},
collection: {
type: String,
required: true,
},
field: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
default: undefined,
},
},
setup(props, { emit }) {
const relationsStore = useRelationsStore();
const openItems = ref([]);
const { relation } = useRelation();
const { info, primaryKeyField } = useCollection(relation.value.one_collection);
const { loading, error, stagedValues, fetchValues, emitValue } = useValues();
const { stageSelection, selectDrawer, selectionFilters } = useSelection();
const { addNewActive, addNew } = useAddNew();
const template = computed(() => {
return props.displayTemplate || info.value?.meta?.display_template || `{{${primaryKeyField.value.field}}}`;
});
onMounted(fetchValues);
watch(() => props.primaryKey, fetchValues, { immediate: true });
const dragging = ref(false);
return {
relation,
openItems,
template,
loading,
error,
stagedValues,
fetchValues,
primaryKeyField,
onDraggableChange,
hideDragImage,
dragging,
emitValue,
stageSelection,
selectDrawer,
selectionFilters,
addNewActive,
addNew,
};
function useValues() {
const loading = ref(false);
const error = ref<any>(null);
const stagedValues = ref<Record<string, any>[]>([]);
return { loading, error, stagedValues, fetchValues, emitValue, getFieldsToFetch };
async function fetchValues() {
if (!props.primaryKey || !relation.value || props.primaryKey === '+') return;
// In case props.value is already an array of edited objects
if (props.value?.length > 0 && props.value.every((item) => typeof item === 'object')) {
stagedValues.value = props.value as Record<string, any>[];
return;
}
loading.value = true;
try {
const response = await api.get(`/items/${props.collection}/${props.primaryKey}`, {
params: {
fields: getFieldsToFetch(),
},
});
stagedValues.value = response.data.data?.[relation.value.one_field!] ?? [];
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
function getFieldsToFetch() {
const fields = [
...new Set([primaryKeyField.value.field, relation.value.one_field, ...getFieldsFromTemplate(template.value)]),
];
const result: string[] = [];
const prefix = `${relation.value.one_field}.`;
for (let i = 1; i <= 5; i++) {
for (const field of fields) {
result.push(`${prefix.repeat(i)}${field}`);
}
}
return result;
}
function emitValue(value: Record<string, any>[]) {
stagedValues.value = value;
if (relation.value.sort_field) {
return emit('input', addSort(value));
}
emit('input', value);
function addSort(value: Record<string, any>[]): Record<string, any>[] {
return (value || []).map((item, index) => {
return {
...item,
[relation.value.sort_field!]: index,
[relation.value.one_field!]: addSort(item[relation.value.one_field!]),
};
});
}
}
}
function useRelation() {
const relation = computed<Relation>(() => {
return relationsStore.getRelationsForField(props.collection, props.field)?.[0];
});
return { relation };
}
function onDraggableChange() {
emit('input', stagedValues.value);
}
function useSelection() {
const selectDrawer = ref(false);
const selectedPrimaryKeys = computed<(number | string)[]>(() => {
if (stagedValues.value === null) return [];
const pkField = primaryKeyField.value.field;
return [props.primaryKey, ...getPKs(stagedValues.value)];
function getPKs(values: Record<string, any>[]): (string | number)[] {
const pks = [];
for (const value of values) {
if (!value[pkField]) continue;
pks.push(value[pkField]);
const childPKs = getPKs(value[relation.value.one_field!]);
pks.push(...childPKs);
}
return pks;
}
});
const selectionFilters = computed<Filter[]>(() => {
const pkField = primaryKeyField.value.field;
if (selectedPrimaryKeys.value.length === 0) return [];
return [
{
key: 'selection',
field: pkField,
operator: 'nin',
value: selectedPrimaryKeys.value.join(','),
locked: true,
},
{
key: 'parent',
field: relation.value.many_field,
operator: 'null',
value: true,
locked: true,
},
] as Filter[];
});
return { stageSelection, selectDrawer, selectionFilters };
async function stageSelection(newSelection: (number | string)[]) {
loading.value = true;
const selection = newSelection.filter((item) => selectedPrimaryKeys.value.includes(item) === false);
const fields = [
...new Set([primaryKeyField.value.field, relation.value.one_field, ...getFieldsFromTemplate(template.value)]),
];
const result: string[] = [];
const prefix = `${relation.value.one_field}.`;
for (let i = 1; i <= 5; i++) {
for (const field of fields) {
result.push(`${prefix.repeat(i)}${field}`);
}
}
const response = await api.get(`/items/${props.collection}`, {
params: {
fields: [...fields, ...result],
filter: {
[primaryKeyField.value.field]: {
_in: selection,
},
},
},
});
const newVal = [...response.data.data, ...stagedValues.value];
if (newVal.length === 0) emitValue([]);
else emitValue(newVal);
loading.value = false;
}
}
function useAddNew() {
const addNewActive = ref(false);
return { addNewActive, addNew };
function addNew(item: Record<string, any>) {
emitValue([...stagedValues.value, item]);
}
}
},
});
</script>
<style lang="scss" scoped>
::v-deep {
ul,
li {
list-style: none;
}
ul {
margin-left: 24px;
padding-left: 0;
}
}
.actions {
margin-top: 12px;
}
.existing {
margin-left: 12px;
}
</style>

View File

@@ -583,6 +583,7 @@ settings_webhooks: Webhooks
settings_presets: Presets & Bookmarks
scope: Scope
layout: Layout
tree_view: Tree View
changes_are_permanent: Changes are permanent
preset_name_placeholder: Name of bookmark...
preset_search_placeholder: Search query...
@@ -984,6 +985,9 @@ interfaces:
translations:
display_template: Display Template
no_collection: No Collection
tree-view:
description: Tree view for nested recursive one-to-many items
recursive_only: The tree view interface only works for recursive relationships.
user:
user: User
description: Select an existing directus user

View File

@@ -253,9 +253,7 @@ export default defineComponent({
const availableCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return (
collection.collection.startsWith('directus_') === false && collection.collection !== props.collection
);
return collection.collection.startsWith('directus_') === false;
}),
['collection'],
['asc']
@@ -265,7 +263,7 @@ export default defineComponent({
const systemCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return collection.collection.startsWith('directus_') === true && collection.collection !== props.collection;
return collection.collection.startsWith('directus_') === true;
}),
['collection'],
['asc']

View File

@@ -101,7 +101,7 @@
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, computed, reactive, PropType, watch, toRefs } from '@vue/composition-api';
import { defineComponent, ref, computed, PropType, toRefs } from '@vue/composition-api';
import SetupTabs from './components/tabs.vue';
import SetupActions from './components/actions.vue';
import SetupSchema from './components/schema.vue';