Add new advanced filters experience (#8570)

* Remove advanced filter sidebar detail

So long, and thanks for all the fish.

* Remove filter conversion logic

* Start replacing/removing old skool filters

* Add inline mode for usages in search bar

* Make filter work in header bar

* Emit empty string as null in filter

* Move shared filter types to shared

* Upgrade use-items

* Fix manual sort on tabular

* Cleanup styling in search bar usage

* Tweak styling

* Fix filtering issues

* Update cards

* Remove activeFilterCount from tabular

* Update maps to work with new filters

* Update calendar to new filter/sort structure

* Fix activity module nav/search

* Fix no-results message

* Update file library filtering

* Finalize user search

* Allow filtering in drawer-collection

* Handle cancelled responses semi-gracefully

* Add loading start state timeout

* Replace sort type in api

* Last commit before redoing a bunch

* Finish new visual style

* Remove unused rounded prop from v-menu

* Tweak sizing

* Enough size tweaking for now

* Count all filter operators instead of top

* Fix archive casting

* Fix api build

* Add merge filters util

* Split filter in user vs system

* Fix export sidebar detail

* Show field label on permissions configuration

* Add migration for filter/sort

* Use filters in insights
This commit is contained in:
Rijk van Zanten
2021-10-07 18:06:03 -04:00
committed by GitHub
parent 046cc8539c
commit f64a5bef7e
99 changed files with 1375 additions and 1760 deletions

View File

@@ -50,6 +50,8 @@ export const onResponse = (response: AxiosResponse | Response): AxiosResponse |
export const onError = async (error: RequestError): Promise<RequestError> => {
const requestsStore = useRequestsStore();
// Note: Cancelled requests don't respond with the config
const id = (error.response?.config as RequestConfig)?.id;
if (id) requestsStore.endRequest(id);

View File

@@ -1,5 +1,4 @@
import ExportSidebarDetail from '@/views/private/components/export-sidebar-detail';
import FilterSidebarDetail from '@/views/private/components/filter-sidebar-detail';
import RenderDisplay from '@/views/private/components/render-display';
import RenderTemplate from '@/views/private/components/render-template';
import SidebarDetail from '@/views/private/components/sidebar-detail/';
@@ -113,7 +112,6 @@ export function registerComponents(app: App): void {
app.component('RenderDisplay', RenderDisplay);
app.component('RenderTemplate', RenderTemplate);
app.component('FilterSidebarDetail', FilterSidebarDetail);
app.component('ExportSidebarDetail', ExportSidebarDetail);
app.component('SidebarDetail', SidebarDetail);
app.component('UserPopover', UserPopover);

View File

@@ -11,12 +11,21 @@ interface HTMLExpandElement extends HTMLElement {
};
}
export default function (expandedParentClass = '', xAxis = false): Record<string, any> {
export default function (
expandedParentClass = '',
xAxis = false,
emit: (
event: 'beforeEnter' | 'enter' | 'afterEnter' | 'enterCancelled' | 'leave' | 'afterLeave' | 'leaveCancelled',
...args: any[]
) => void
): Record<string, any> {
const sizeProperty = xAxis ? 'width' : ('height' as 'width' | 'height');
const offsetProperty = `offset${capitalizeFirst(sizeProperty)}` as 'offsetHeight' | 'offsetWidth';
return {
beforeEnter(el: HTMLExpandElement) {
emit('beforeEnter');
el._parent = el.parentNode as (Node & ParentNode & HTMLElement) | null;
el._initialStyle = {
transition: el.style.transition,
@@ -27,6 +36,8 @@ export default function (expandedParentClass = '', xAxis = false): Record<string
},
enter(el: HTMLExpandElement) {
emit('enter');
const initialStyle = el._initialStyle;
if (!initialStyle) return;
const offset = `${el[offsetProperty]}px`;
@@ -51,10 +62,19 @@ export default function (expandedParentClass = '', xAxis = false): Record<string
});
},
afterEnter: resetStyles,
enterCancelled: resetStyles,
afterEnter(el: HTMLExpandElement) {
emit('afterEnter');
resetStyles(el);
},
enterCancelled(el: HTMLExpandElement) {
emit('enterCancelled');
resetStyles(el);
},
leave(el: HTMLExpandElement) {
emit('leave');
el._initialStyle = {
transition: '',
visibility: '',
@@ -69,16 +89,25 @@ export default function (expandedParentClass = '', xAxis = false): Record<string
requestAnimationFrame(() => (el.style[sizeProperty] = '0'));
},
afterLeave,
leaveCancelled: afterLeave,
};
afterLeave(el: HTMLExpandElement) {
emit('afterLeave');
function afterLeave(el: HTMLExpandElement) {
if (expandedParentClass && el._parent) {
el._parent.classList.remove(expandedParentClass);
}
resetStyles(el);
}
if (expandedParentClass && el._parent) {
el._parent.classList.remove(expandedParentClass);
}
resetStyles(el);
},
leaveCancelled(el: HTMLExpandElement) {
emit('leaveCancelled');
if (expandedParentClass && el._parent) {
el._parent.classList.remove(expandedParentClass);
}
resetStyles(el);
},
};
function resetStyles(el: HTMLExpandElement) {
if (!el._initialStyle) return;

View File

@@ -19,8 +19,9 @@ export default defineComponent({
default: '',
},
},
setup(props) {
const methods = ExpandMethods(props.expandedParentClass, props.xAxis);
emits: ['beforeEnter', 'enter', 'afterEnter', 'enterCancelled', 'leave', 'afterLeave', 'leaveCancelled'],
setup(props, { emit }) {
const methods = ExpandMethods(props.expandedParentClass, props.xAxis, emit);
return { methods };
},
});

View File

@@ -46,8 +46,8 @@ export default defineComponent({
});
</script>
<style>
body {
<style lang="scss" scoped>
:global(body) {
--v-badge-color: var(--white);
--v-badge-background-color: var(--danger);
--v-badge-border-color: var(--background-page);
@@ -55,9 +55,7 @@ body {
--v-badge-offset-y: 0px;
--v-badge-size: 16px;
}
</style>
<style lang="scss" scoped>
.v-badge {
position: relative;
display: inline-block;

View File

@@ -12,7 +12,9 @@ import { onUnmounted, ref, Ref, watch } from 'vue';
export function usePopper(
reference: Ref<HTMLElement | null>,
popper: Ref<HTMLElement | null>,
options: Readonly<Ref<Readonly<{ placement: Placement; attached: boolean; arrow: boolean }>>>
options: Readonly<
Ref<Readonly<{ placement: Placement; attached: boolean; arrow: boolean; offsetY: number; offsetX: number }>>
>
): Record<string, any> {
const popperInstance = ref<Instance | null>(null);
const styles = ref({});
@@ -72,7 +74,7 @@ export function usePopper(
{
...offset,
options: {
offset: options.value.attached ? [0, 0] : [0, 8],
offset: options.value.attached ? [0, 0] : [options.value.offsetX ?? 0, options.value.offsetY ?? 8],
},
},
{

View File

@@ -103,6 +103,14 @@ export default defineComponent({
type: Number,
default: 0,
},
offsetY: {
type: Number,
default: 8,
},
offsetX: {
type: Number,
default: 0,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
@@ -138,6 +146,8 @@ export default defineComponent({
placement: props.placement,
attached: props.attached,
arrow: props.showArrow,
offsetY: props.offsetY,
offsetX: props.offsetX,
}))
);
@@ -311,17 +321,16 @@ body {
}
.arrow,
.arrow::before,
.arrow::after {
position: absolute;
z-index: 1;
width: 10px;
height: 10px;
border-radius: 2px;
box-shadow: none;
}
.arrow {
&::before,
&::after {
background: var(--card-face-color);
transform: rotate(45deg) scale(0);
@@ -330,16 +339,10 @@ body {
content: '';
}
&.active::before,
&.active::after {
transform: rotate(45deg) scale(1);
transition: transform var(--medium) var(--transition-in);
}
&::after {
background: var(--card-face-color);
box-shadow: -2.5px -2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
}
}
[data-placement^='top'] .arrow {
@@ -347,7 +350,7 @@ body {
&::after {
bottom: 2px;
box-shadow: 2.5px 2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
// box-shadow: 2.5px 2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
}
}
@@ -356,7 +359,7 @@ body {
&::after {
top: 2px;
box-shadow: -2.5px -2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
// box-shadow: -2.5px -2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
}
}
@@ -365,7 +368,7 @@ body {
&::after {
left: 2px;
box-shadow: -2.5px 2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
// box-shadow: -2.5px 2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
}
}
@@ -374,7 +377,7 @@ body {
&::after {
right: 2px;
box-shadow: 2.5px -2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
// box-shadow: 2.5px -2.5px 4px 0px rgba(var(--card-shadow-color), 0.2);
}
}

View File

@@ -2,7 +2,7 @@ import { useFieldsStore, useRelationsStore } from '@/stores/';
import { Field, Relation } from '@directus/shared/types';
import { getRelationType } from '@directus/shared/utils';
import { get, set } from 'lodash';
import { computed, Ref, ref, ComputedRef } from 'vue';
import { computed, Ref, ref, ComputedRef, watch } from 'vue';
export type FieldTree = Record<string, FieldInfo>;
export type FieldInfo = { name: string; field: string; children: FieldTree; collection: string; type: string };
@@ -31,6 +31,12 @@ export function useFieldTree(
tree.value = getFieldTreeForCollection(collection.value, 'any');
}
watch(collection, () => {
if (collection.value) {
tree.value = getFieldTreeForCollection(collection.value, 'any');
}
});
const visitedRelations = ref<string[][]>([]);
Object.values(tree.value).forEach((value) => {

View File

@@ -1,9 +1,9 @@
import { getLayouts } from '@/layouts';
import { computed, reactive, toRefs, defineComponent, Ref, PropType, Component, ComputedRef } from 'vue';
import { AppFilter, Item, LayoutConfig } from '@directus/shared/types';
import { Filter, Item, LayoutConfig } from '@directus/shared/types';
const NAME_SUFFIX = 'wrapper';
const WRITABLE_PROPS = ['selection', 'layoutOptions', 'layoutQuery', 'filters', 'searchQuery'] as const;
const WRITABLE_PROPS = ['selection', 'layoutOptions', 'layoutQuery'] as const;
type WritableProp = typeof WRITABLE_PROPS[number];
@@ -31,11 +31,19 @@ function createLayoutWrapper<Options, Query>(layout: LayoutConfig): Component {
type: Object as PropType<Query>,
default: () => ({}),
},
filters: {
type: Array as PropType<AppFilter[]>,
default: () => [],
filter: {
type: Object as PropType<Filter>,
default: null,
},
searchQuery: {
filterUser: {
type: Object as PropType<Filter>,
default: null,
},
filterSystem: {
type: Object as PropType<Filter>,
default: null,
},
search: {
type: String as PropType<string | null>,
default: null,
},

View File

@@ -1,14 +1,9 @@
export type Filter = {
locked?: boolean;
field: string;
operator: string;
value: string | number;
};
import { Filter } from '@directus/shared/types';
export type Preset = {
id: number;
collection: string;
filters: null | Filter[];
filter: Filter | null;
role: number | null;
search: string | null;
title: string | null;

View File

@@ -1,4 +1,3 @@
import { useCollection } from '@directus/shared/composables';
import { usePresetsStore, useUserStore } from '@/stores';
import { Filter, Preset } from '@directus/shared/types';
import { debounce, isEqual } from 'lodash';
@@ -9,8 +8,8 @@ type UsablePreset = {
layout: Ref<string | null>;
layoutOptions: Ref<Record<string, any>>;
layoutQuery: Ref<Record<string, any>>;
filters: Ref<readonly Filter[]>;
searchQuery: Ref<string | null>;
filter: Ref<Filter | null>;
search: Ref<string | null>;
refreshInterval: Ref<number | null>;
savePreset: (preset?: Partial<Preset> | undefined) => Promise<any>;
saveCurrentAsBookmark: (overrides: Partial<Preset>) => Promise<any>;
@@ -33,7 +32,7 @@ export function usePreset(
const busy = ref(false);
const { info: collectionInfo } = useCollection(collection);
// const { info: collectionInfo } = useCollection(collection);
const bookmarkExists = computed(() => {
if (!bookmark.value) return false;
@@ -71,7 +70,7 @@ export function usePreset(
/**
* If no bookmark is present, save periodically to the DB,
* otherwhise update the saved status if changes where made.
* otherwise update the saved status if changes where made.
*/
function handleChanges() {
if (bookmarkExists.value) {
@@ -139,14 +138,14 @@ export function usePreset(
},
});
const filters = computed({
const filter = computed<Filter | null>({
get() {
return localPreset.value.filters || [];
return localPreset.value.filter ?? null;
},
set(val: readonly Filter[]) {
set(val: Filter | null) {
localPreset.value = {
...localPreset.value,
filters: val,
filter: val,
};
handleChanges();
@@ -167,7 +166,7 @@ export function usePreset(
},
});
const searchQuery = computed<string | null>({
const search = computed<string | null>({
get() {
return localPreset.value.search || null;
},
@@ -201,8 +200,8 @@ export function usePreset(
layout,
layoutOptions,
layoutQuery,
filters,
searchQuery,
filter,
search,
refreshInterval,
savePreset,
saveCurrentAsBookmark,
@@ -230,7 +229,7 @@ export function usePreset(
layout_query: null,
layout_options: null,
layout: 'tabular',
filters: null,
filter: null,
search: null,
refresh_interval: null,
};
@@ -258,20 +257,20 @@ export function usePreset(
};
}
if (collectionInfo.value?.meta?.archive_field && collectionInfo.value?.meta?.archive_app_filter === true) {
localPreset.value = {
...localPreset.value,
filters: localPreset.value.filters || [
{
key: 'hide-archived',
field: collectionInfo.value.meta.archive_field,
operator: 'neq',
value: collectionInfo.value.meta.archive_value!,
locked: true,
},
],
};
}
// if (collectionInfo.value?.meta?.archive_field && collectionInfo.value?.meta?.archive_app_filter === true) {
// localPreset.value = {
// ...localPreset.value,
// filter: localPreset.value.filter || [
// {
// key: 'hide-archived',
// field: collectionInfo.value.meta.archive_field,
// operator: 'neq',
// value: collectionInfo.value.meta.archive_value!,
// locked: true,
// },
// ],
// };
// }
}
/**

View File

@@ -9,12 +9,12 @@
/>
<input
v-else-if="is === 'interface-input'"
ref="inputEl"
:type="type"
:value="value"
:style="{ width }"
autofocus
placeholder="--"
@input="$emit('input', $event.target.value)"
@input="emitValue($event.target.value)"
/>
<v-menu v-else :close-on-content-click="false" :show-arrow="true" placement="bottom-start">
<template #activator="{ toggle }">
@@ -27,13 +27,13 @@
<div v-else class="preview" @click="toggle">{{ displayValue }}</div>
</template>
<div class="input" :class="type">
<component :is="is" class="input-component" small :type="type" :value="value" @input="$emit('input', $event)" />
<component :is="is" class="input-component" small :type="type" :value="value" @input="emitValue($event)" />
</div>
</v-menu>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue';
import { computed, defineComponent, PropType, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
export default defineComponent({
@@ -52,7 +52,8 @@ export default defineComponent({
},
},
emits: ['input'],
setup(props) {
setup(props, { emit }) {
const inputEl = ref<HTMLElement>();
const { t } = useI18n();
const displayValue = computed(() => {
@@ -70,10 +71,23 @@ export default defineComponent({
if (props.is === 'interface-input' && typeof props.value === 'string') {
return (props.value?.length >= 3 ? props.value.length + 1 : 3) + 'ch';
}
return 3 + 'ch';
});
return { displayValue, width, t };
onMounted(() => {
inputEl.value?.focus();
});
return { displayValue, width, t, emitValue, inputEl };
function emitValue(val: unknown) {
if (val === '') {
emit('input', null);
} else {
emit('input', val);
}
}
},
});
</script>

View File

@@ -1,21 +1,22 @@
<template>
<draggable
:group="{ name: 'g1' }"
:list="filter"
tag="ul"
draggable=".row"
handle=".drag-handle"
:item-key="getIndex"
tag="ul"
:swap-threshold="0.3"
class="group"
:list="filterSync"
:group="{ name: 'g1' }"
:item-key="getIndex"
:swap-threshold="0.3"
:force-fallback="true"
@change="$emit('change')"
>
<template #item="{ element, index }">
<li class="row">
<div v-if="filterInfo[index].isField" block class="node field">
<div class="header">
<div class="header" :class="{ inline }">
<v-icon name="drag_indicator" class="drag-handle" small></v-icon>
<v-select
v-tooltip.monospace="filterInfo[index].field"
inline
class="name"
item-text="name"
@@ -39,7 +40,7 @@
:items="getCompareOptions(filterInfo[index].field)"
@update:modelValue="updateComparator(index, $event)"
/>
<input-group :field="element" :collection="collection" @update:field="updateNode(index, $event)" />
<input-group :field="element" :collection="collection" @update:field="replaceNode(index, $event)" />
<span class="delete">
<v-icon
v-tooltip="t('delete_label')"
@@ -53,7 +54,7 @@
</div>
<div v-else class="node logic">
<div class="header">
<div class="header" :class="{ inline }">
<v-icon name="drag_indicator" class="drag-handle" small />
<div class="logic-type" :class="{ or: filterInfo[index].name === '_or' }">
<span class="key" @click="toggleLogic(index)">
@@ -75,8 +76,10 @@
:filter="element[filterInfo[index].name]"
:collection="collection"
:depth="depth + 1"
:inline="inline"
@change="$emit('change')"
@remove-node="$emit('remove-node', [`${index}.${filterInfo[index].name}`, ...$event])"
@update:filter="updateNode(index, { [filterInfo[index].name]: $event })"
@update:filter="replaceNode(index, { [filterInfo[index].name]: $event })"
/>
</div>
</li>
@@ -93,7 +96,7 @@ import { useFieldsStore } from '@/stores';
import { useI18n } from 'vue-i18n';
import { getFilterOperatorsForType } from '@directus/shared/utils';
import { get } from 'lodash';
import { FieldFilter, Filter, FilterOperator } from '@directus/shared/types';
import { FieldFilter, Filter, FilterOperator, LogicalFilterAND, LogicalFilterOR } from '@directus/shared/types';
import { useSync } from '@directus/shared/composables';
import { fieldToFilter, getField, getNodeName, getComparator } from './utils';
@@ -132,8 +135,12 @@ export default defineComponent({
type: Number,
default: 1,
},
inline: {
type: Boolean,
default: false,
},
},
emits: ['remove-node', 'update:filter'],
emits: ['remove-node', 'update:filter', 'change'],
setup(props, { emit }) {
const { collection } = toRefs(props);
const filterSync = useSync(props, 'filter', emit);
@@ -173,7 +180,7 @@ export default defineComponent({
updateField,
updateComparator,
t,
updateNode,
replaceNode,
toggleLogic,
loadFieldRelations,
getNodeName,
@@ -182,6 +189,7 @@ export default defineComponent({
filterInfo,
getIndex,
getFieldPreview,
filterSync,
};
function getFieldPreview(node: Record<string, any>) {
@@ -204,12 +212,25 @@ export default defineComponent({
function toggleLogic(index: number) {
const nodeInfo = filterInfo.value[index];
if (nodeInfo.isField) return;
if (filterInfo.value[index].isField) return;
if ('_and' in nodeInfo.node) {
filterSync.value[index] = { _or: nodeInfo.node._and as FieldFilter[] };
filterSync.value = filterSync.value.map((filter, filterIndex) => {
if (filterIndex === index) {
return { _or: (nodeInfo.node as LogicalFilterAND)._and as FieldFilter[] };
}
return filter;
});
} else {
filterSync.value[index] = { _and: nodeInfo.node._or as FieldFilter[] };
filterSync.value = filterSync.value.map((filter, filterIndex) => {
if (filterIndex === index) {
return { _and: (nodeInfo.node as LogicalFilterOR)._or as FieldFilter[] };
}
return filter;
});
}
}
@@ -233,7 +254,11 @@ export default defineComponent({
function update(value: any) {
if (nodeInfo.isField === false) return;
filterSync.value[index] = fieldToFilter(nodeInfo.field, newVal, value);
filterSync.value = filterSync.value.map((filter, filterIndex) => {
if (filterIndex === index) return fieldToFilter(nodeInfo.field, newVal, value);
return filter;
});
}
}
@@ -253,16 +278,23 @@ export default defineComponent({
comparator = getCompareOptions(newField)[0].value;
}
filterSync.value[index] = fieldToFilter(newField, comparator, value);
filterSync.value = filterSync.value.map((filter, filterIndex) => {
if (filterIndex === index) return fieldToFilter(newField, comparator, value);
return filter;
});
}
function updateNode(index: number, field: Filter) {
filterSync.value = filterSync.value.map((val, i) => (i === index ? field : val));
function replaceNode(index: number, newFilter: Filter) {
filterSync.value = filterSync.value.map((val, filterIndex) => {
if (filterIndex === index) return newFilter;
return val;
});
}
function getCompareOptions(name: string) {
const fieldInfo = fieldsStore.getField(props.collection, name);
if (fieldInfo === null) return [];
return getFilterOperatorsForType(fieldInfo.type).map((type) => ({
text: t(`operators.${type}`),
value: `_${type}`,
@@ -278,6 +310,7 @@ export default defineComponent({
display: flex;
align-items: center;
width: fit-content;
margin-right: 12px;
margin-bottom: 8px;
padding: 2px 6px;
padding-right: 8px;
@@ -385,6 +418,18 @@ export default defineComponent({
margin-right: 4px;
cursor: grab;
}
&.inline {
width: auto;
margin-right: 0;
padding-right: 12px;
.delete {
right: 8px;
left: unset;
background-color: var(--background-page);
}
}
}
.group :deep(.sortable-ghost) {

View File

@@ -1,40 +1,52 @@
<template>
<div class="system-filter">
<v-list :mandatory="true" :class="{ empty: innerValue.length === 0 }">
<v-notice v-if="!collectionField && !collection" type="warning">
{{ t('collection_field_not_setup') }}
</v-notice>
<v-notice v-else-if="!collection" type="warning">
{{ t('select_a_collection') }}
</v-notice>
<div v-else class="system-filter" :class="{ inline, empty: innerValue.length === 0 }">
<v-list :mandatory="true">
<div v-if="innerValue.length === 0" class="no-rules">
{{ t('interfaces.filter.no_rules') }}
</div>
<nodes
v-else
v-model:filter="innerValue"
:collection="collectionName"
:collection="collection"
:depth="1"
@remove-node="removeNode($event)"
@change="emitValue"
/>
</v-list>
<div class="buttons">
<v-select
inline
:inline="!inline"
item-text="name"
item-value="key"
placement="bottom-start"
class="add-filter"
:placeholder="t('interfaces.filter.add_filter')"
:full-width="false"
:full-width="inline"
:model-value="null"
:items="fieldOptions"
:mandatory="false"
:groups-clickable="true"
@group-toggle="loadFieldRelations($event.value, 1)"
@update:modelValue="addNode($event)"
/>
>
<template v-if="inline" #prepend>
<v-icon name="add" small />
</template>
</v-select>
</div>
</div>
</template>
<script lang="ts">
import { get, set, isEmpty } from 'lodash';
import { defineComponent, PropType, computed, toRefs } from 'vue';
import { get, set, isEmpty, cloneDeep } from 'lodash';
import { defineComponent, PropType, computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Filter, FieldFilter } from '@directus/shared/types';
import Nodes from './nodes.vue';
@@ -58,14 +70,29 @@ export default defineComponent({
type: String,
default: null,
},
collectionField: {
type: String,
default: null,
},
// Inline = stylistic rendering without borders. Used inside search-input
inline: {
type: Boolean,
default: false,
},
},
emits: ['input'],
setup(props, { emit }) {
const { t } = useI18n();
const { collectionName } = toRefs(props);
const values = inject('values', ref<Record<string, any>>({}));
const { treeList, loadFieldRelations } = useFieldTree(collectionName);
const collection = computed(() => {
if (props.collectionName) return props.collectionName;
return values.value[props.collectionField] ?? null;
});
const { treeList, loadFieldRelations } = useFieldTree(collection);
const innerValue = computed<Filter[]>({
get() {
@@ -74,9 +101,9 @@ export default defineComponent({
const name = getNodeName(props.value);
if (name === '_and') {
return props.value['_and'];
return cloneDeep(props.value['_and']);
} else {
return [props.value];
return cloneDeep([props.value]);
}
},
set(newVal) {
@@ -92,7 +119,24 @@ export default defineComponent({
return [{ key: '$group', name: t('interfaces.filter.add_group') }, { divider: true }, ...treeList.value];
});
return { t, addNode, removeNode, innerValue, fieldOptions, loadFieldRelations };
return {
t,
addNode,
removeNode,
innerValue,
fieldOptions,
loadFieldRelations,
emitValue,
collection,
};
function emitValue() {
if (innerValue.value.length === 0) {
emit('input', null);
} else {
emit('input', { _and: innerValue.value });
}
}
function addNode(key: string) {
if (key === '$group') {
@@ -155,32 +199,70 @@ export default defineComponent({
}
.buttons {
display: flex;
gap: 10px;
padding: 0 10px;
color: var(--primary);
font-weight: 700;
.add {
cursor: pointer;
}
font-weight: 600;
}
.empty {
display: flex;
align-items: center;
height: var(--input-height);
padding-top: 0;
padding-bottom: 0;
&.empty {
.v-list {
display: flex;
align-items: center;
height: var(--input-height);
padding-top: 0;
padding-bottom: 0;
}
.no-rules {
color: var(--foreground-subdued);
font-family: var(--family-monospace);
}
}
}
.add-filter {
--v-select-placeholder-color: var(--primary);
.add-filter {
--v-select-placeholder-color: var(--primary);
}
&.inline {
.v-list {
margin: 0;
padding: 0;
border: 0;
}
&.empty .v-list {
display: none;
}
.buttons {
margin: 0;
padding: 0;
}
.add-filter {
width: 100%;
:deep(.v-input) {
position: relative;
width: 100%;
height: 30px;
padding: 0;
background-color: var(--background-page);
border: var(--border-width) solid var(--border-subdued);
border-radius: 100px;
transition: border-color var(--fast) var(--transition);
.input {
padding-right: 5px;
padding-left: 6px;
background: transparent;
border: 0;
.prepend {
margin-right: 4px;
}
}
}
}
}
}
</style>

View File

@@ -83,7 +83,6 @@
v-model:active="selectModalActive"
:collection="relationCollection.collection"
:selection="[]"
:filters="selectionFilters"
multiple
@input="stageSelection"
/>
@@ -210,7 +209,7 @@ export default defineComponent({
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit, relatedPrimaryKey, editModalActive } =
useEdit(value, relationInfo, emitter);
const { stageSelection, selectModalActive, selectionFilters } = useSelection(value, items, relationInfo, emitter);
const { stageSelection, selectModalActive } = useSelection(items, relationInfo, emitter);
const { sort, sortItems, sortedItems } = useSort(relationInfo, fields, items, emitter);
const { createAllowed, selectAllowed } = usePermissions(junctionCollection, relationCollection);
@@ -238,7 +237,6 @@ export default defineComponent({
stageSelection,
selectModalActive,
deleteItem,
selectionFilters,
items,
relationInfo,
relatedPrimaryKey,

View File

@@ -40,7 +40,6 @@
v-model:active="selectDrawer"
:collection="collection"
:selection="[]"
:filters="selectionFilters"
multiple
@input="stageSelection"
/>
@@ -56,7 +55,7 @@ import api from '@/api';
import { getFieldsFromTemplate } from '@directus/shared/utils';
import hideDragImage from '@/utils/hide-drag-image';
import NestedDraggable from './nested-draggable.vue';
import { Filter, Relation } from '@directus/shared/types';
import { Relation } from '@directus/shared/types';
import DrawerCollection from '@/views/private/components/drawer-collection';
import DrawerItem from '@/views/private/components/drawer-item';
@@ -107,7 +106,7 @@ export default defineComponent({
const { info, primaryKeyField } = useCollection(relation.value.related_collection!);
const { loading, error, stagedValues, fetchValues, emitValue } = useValues();
const { stageSelection, selectDrawer, selectionFilters } = useSelection();
const { stageSelection, selectDrawer } = useSelection();
const { addNewActive, addNew } = useAddNew();
const template = computed(() => {
@@ -253,30 +252,7 @@ export default defineComponent({
}
});
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.field,
operator: 'null',
value: true,
locked: true,
},
] as Filter[];
});
return { stageSelection, selectDrawer, selectionFilters };
return { stageSelection, selectDrawer };
async function stageSelection(newSelection: (number | string)[]) {
loading.value = true;

View File

@@ -8,7 +8,7 @@ import { getFieldsFromTemplate } from '@directus/shared/utils';
import getFullcalendarLocale from '@/utils/get-fullcalendar-locale';
import { renderPlainStringTemplate } from '@/utils/render-string-template';
import { unexpectedError } from '@/utils/unexpected-error';
import { Field, Item } from '@directus/shared/types';
import { Field, Item, Filter } from '@directus/shared/types';
import { defineLayout } from '@directus/shared/utils';
import { Calendar, CalendarOptions as FullCalendarOptions, EventInput } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
@@ -20,7 +20,6 @@ import { useI18n } from 'vue-i18n';
import CalendarActions from './actions.vue';
import CalendarLayout from './calendar.vue';
import CalendarOptions from './options.vue';
import CalendarSidebar from './sidebar.vue';
import { useSync } from '@directus/shared/composables';
import { LayoutOptions } from './types';
@@ -31,7 +30,8 @@ export default defineLayout<LayoutOptions>({
component: CalendarLayout,
slots: {
options: CalendarOptions,
sidebar: CalendarSidebar,
// eslint-disable-next-line @typescript-eslint/no-empty-function
sidebar: () => {},
actions: CalendarActions,
},
setup(props, { emit }) {
@@ -42,8 +42,8 @@ export default defineLayout<LayoutOptions>({
const appStore = useAppStore();
const layoutOptions = useSync(props, 'layoutOptions', emit);
const filters = useSync(props, 'filters', emit);
const searchQuery = useSync(props, 'searchQuery', emit);
const filter = useSync(props, 'filter', emit);
const search = useSync(props, 'search', emit);
const { selection, collection } = toRefs(props);
@@ -55,26 +55,24 @@ export default defineLayout<LayoutOptions>({
})
);
const filtersWithCalendarView = computed(() => {
if (!calendar.value || !startDateField.value) return filters.value;
const filterWithCalendarView = computed(() => {
if (!calendar.value || !startDateField.value) return filter.value;
return [
...filters.value,
{
key: 'start_date',
field: startDateField.value,
operator: 'gte',
value: formatISO(calendar.value.view.currentStart),
hidden: true,
},
{
key: 'end_date',
field: startDateField.value,
operator: 'lte',
value: formatISO(calendar.value.view.currentEnd),
hidden: true,
},
];
return {
_and: [
filter.value,
{
[startDateField.value]: {
_gte: formatISO(calendar.value.view.currentStart),
},
},
{
[startDateField.value]: {
_lte: formatISO(calendar.value.view.currentEnd),
},
},
],
} as Filter;
});
const template = computed({
@@ -136,7 +134,7 @@ export default defineLayout<LayoutOptions>({
const { items, loading, error, totalPages, itemCount, totalCount, changeManualSort, getItems } = useItems(
collection,
{
sort: computed(() => primaryKeyField.value?.field || ''),
sort: computed(() => [primaryKeyField.value?.field || '']),
page: ref(1),
limit: ref(-1),
fields: computed(() => {
@@ -148,8 +146,8 @@ export default defineLayout<LayoutOptions>({
if (endDateField.value) fields.push(endDateField.value);
return fields;
}),
filters: filtersWithCalendarView,
searchQuery: searchQuery,
filter: filterWithCalendarView,
search: search,
},
false
);
@@ -269,7 +267,7 @@ export default defineLayout<LayoutOptions>({
totalCount,
changeManualSort,
getItems,
filtersWithCalendarView,
filterWithCalendarView,
template,
dateFields,
startDateField,

View File

@@ -1,43 +0,0 @@
<template>
<filter-sidebar-detail v-model="filtersWritable" :collection="collection" :loading="loading" />
<export-sidebar-detail :filters="filtersWithCalendarView" :search-query="searchQuery" :collection="collection" />
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { AppFilter } from '@directus/shared/types';
import { useSync } from '@directus/shared/composables';
export default defineComponent({
inheritAttrs: false,
props: {
collection: {
type: String,
required: true,
},
filters: {
type: Array as PropType<AppFilter[]>,
default: () => [],
},
searchQuery: {
type: String as PropType<string | null>,
default: null,
},
loading: {
type: Boolean,
required: true,
},
filtersWithCalendarView: {
type: Array as PropType<AppFilter[]>,
required: true,
},
},
emits: ['update:filters'],
setup(props, { emit }) {
const filtersWritable = useSync(props, 'filters', emit);
return { filtersWritable };
},
});
</script>

View File

@@ -10,13 +10,8 @@
/>
<div class="grid" :class="{ 'single-row': isSingleRow }">
<template v-if="loading">
<card v-for="n in 6" :key="`loader-${n}`" item-key="loading" loading />
</template>
<card
v-for="item in items"
v-else
:key="item[primaryKeyField.field]"
v-model="selectionWritable"
:item-key="primaryKeyField.field"
@@ -73,7 +68,7 @@
</template>
</v-info>
<slot v-else-if="itemCount === 0 && activeFilterCount > 0" name="no-results" />
<slot v-else-if="itemCount === 0 && (filter || search)" name="no-results" />
<slot v-else-if="itemCount === 0" name="no-items" />
</div>
</template>
@@ -87,7 +82,7 @@ import CardsHeader from './components/header.vue';
import useElementSize from '@/composables/use-element-size';
import { Field, Item } from '@directus/shared/types';
import { useSync } from '@directus/shared/composables';
import { Collection } from '@directus/shared/types';
import { Collection, Filter } from '@directus/shared/types';
export default defineComponent({
components: { Card, CardsHeader },
@@ -178,7 +173,7 @@ export default defineComponent({
required: true,
},
sort: {
type: String,
type: Array as PropType<string[]>,
required: true,
},
info: {
@@ -193,10 +188,6 @@ export default defineComponent({
type: Number,
required: true,
},
activeFilterCount: {
type: Number,
required: true,
},
selectAll: {
type: Function as PropType<() => void>,
required: true,
@@ -205,6 +196,14 @@ export default defineComponent({
type: Function as PropType<() => Promise<void>>,
required: true,
},
filter: {
type: Object as PropType<Filter>,
default: null,
},
search: {
type: String,
default: null,
},
},
emits: ['update:selection', 'update:limit', 'update:size', 'update:sort', 'update:width'],
setup(props, { emit }) {

View File

@@ -1,9 +1,9 @@
<template>
<div class="cards-header">
<div class="start">
<div v-if="internalSelection.length > 0" class="selected" @click="internalSelection = []">
<div v-if="selectionSync.length > 0" class="selected" @click="selectionSync = []">
<v-icon name="cancel" outline />
<span class="label">{{ t('n_items_selected', internalSelection.length) }}</span>
<span class="label">{{ t('n_items_selected', selectionSync.length) }}</span>
</div>
<button v-else class="select-all" @click="$emit('select-all')">
<v-icon name="check_circle" outline />
@@ -33,7 +33,7 @@
:disabled="field.disabled"
:active="field.field === sortKey"
clickable
@click="internalSort = field.field"
@click="sortSync = [field.field]"
>
<v-list-item-content>{{ field.name }}</v-list-item-content>
</v-list-item>
@@ -68,7 +68,7 @@ export default defineComponent({
required: true,
},
sort: {
type: String,
type: Array as PropType<string[]>,
required: true,
},
selection: {
@@ -80,12 +80,13 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const internalSize = useSync(props, 'size', emit);
const internalSort = useSync(props, 'sort', emit);
const internalSelection = useSync(props, 'selection', emit);
const descending = computed(() => props.sort.startsWith('-'));
const sizeSync = useSync(props, 'size', emit);
const sortSync = useSync(props, 'sort', emit);
const selectionSync = useSync(props, 'selection', emit);
const sortKey = computed(() => (props.sort.startsWith('-') ? props.sort.substring(1) : props.sort));
const descending = computed(() => props.sort[0].startsWith('-'));
const sortKey = computed(() => (props.sort[0].startsWith('-') ? props.sort[0].substring(1) : props.sort[0]));
const sortField = computed(() => {
return props.fields.find((field) => field.field === sortKey.value);
@@ -107,26 +108,26 @@ export default defineComponent({
descending,
toggleDescending,
sortField,
internalSize,
internalSort,
internalSelection,
sizeSync,
sortSync,
selectionSync,
sortKey,
fieldsWithoutFake,
};
function toggleSize() {
if (props.size >= 2 && props.size < 5) {
internalSize.value++;
sizeSync.value++;
} else {
internalSize.value = 2;
sizeSync.value = 2;
}
}
function toggleDescending() {
if (descending.value === true) {
internalSort.value = internalSort.value.substring(1);
sortSync.value = [sortSync.value[0].substring(1)];
} else {
internalSort.value = '-' + internalSort.value;
sortSync.value = ['-' + sortSync.value];
}
}
},

View File

@@ -1,7 +1,6 @@
import { defineLayout } from '@directus/shared/utils';
import CardsLayout from './cards.vue';
import CardsOptions from './options.vue';
import CardsSidebar from './sidebar.vue';
import CardsActions from './actions.vue';
import { useI18n } from 'vue-i18n';
@@ -23,7 +22,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
component: CardsLayout,
slots: {
options: CardsOptions,
sidebar: CardsSidebar,
// eslint-disable-next-line @typescript-eslint/no-empty-function
sidebar: () => {},
actions: CardsActions,
},
setup(props, { emit }) {
@@ -36,10 +36,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
const selection = useSync(props, 'selection', emit);
const layoutOptions = useSync(props, 'layoutOptions', emit);
const layoutQuery = useSync(props, 'layoutQuery', emit);
const filters = useSync(props, 'filters', emit);
const searchQuery = useSync(props, 'searchQuery', emit);
const { collection } = toRefs(props);
const { collection, filter, search, filterUser } = toRefs(props);
const { info, primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
@@ -66,13 +64,13 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
sort,
limit,
page,
fields: fields,
filters: filters,
searchQuery: searchQuery,
fields,
filter,
search,
});
const showingCount = computed(() => {
if ((itemCount.value || 0) < (totalCount.value || 0)) {
if ((itemCount.value || 0) < (totalCount.value || 0) && filterUser.value) {
if (itemCount.value === 1) {
return t('one_filtered_item');
}
@@ -82,9 +80,11 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
count: n(itemCount.value || 0),
});
}
if (itemCount.value === 1) {
return t('one_item');
}
return t('start_end_of_count_items', {
start: n((+page.value - 1) * limit.value + 1),
end: n(Math.min(page.value * limit.value, itemCount.value || 0)),
@@ -99,10 +99,6 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
return cardsWidth <= width.value;
});
const activeFilterCount = computed(() => {
return filters.value.filter((filter) => !filter.locked).length;
});
return {
items,
loading,
@@ -128,10 +124,11 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
showingCount,
isSingleRow,
width,
activeFilterCount,
refresh,
selectAll,
resetPresetAndRefresh,
filter,
search,
};
async function resetPresetAndRefresh() {
@@ -189,11 +186,11 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
},
});
const sort = computed({
const sort = computed<string[]>({
get() {
return layoutQuery.value?.sort || primaryKeyField.value?.field || '';
return layoutQuery.value?.sort || [primaryKeyField.value!.field] || [];
},
set(newSort: string) {
set(newSort: string[]) {
layoutQuery.value = {
...(layoutQuery.value || {}),
page: 1,
@@ -232,14 +229,6 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
fields.push('type');
}
if (sort.value) {
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
if (fields.includes(sortField) === false) {
fields.push(sortField);
}
}
const titleSubtitleFields: string[] = [];
if (title.value) {

View File

@@ -1,49 +0,0 @@
<template>
<filter-sidebar-detail v-model="filtersWritable" :collection="collection" :loading="loading" />
<export-sidebar-detail
:layout-query="layoutQuery"
:filters="filters"
:search-query="searchQuery"
:collection="collection"
/>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { LayoutQuery } from './types';
import { AppFilter } from '@directus/shared/types';
import { useSync } from '@directus/shared/composables';
export default defineComponent({
inheritAttrs: false,
props: {
collection: {
type: String,
required: true,
},
layoutQuery: {
type: Object as PropType<LayoutQuery>,
default: () => ({}),
},
filters: {
type: Array as PropType<AppFilter[]>,
default: () => [],
},
searchQuery: {
type: String as PropType<string | null>,
default: null,
},
loading: {
type: Boolean,
required: true,
},
},
emits: ['update:filters'],
setup(props, { emit }) {
const filtersWritable = useSync(props, 'filters', emit);
return { filtersWritable };
},
});
</script>

View File

@@ -9,7 +9,7 @@ export type LayoutOptions = {
export type LayoutQuery = {
fields?: string[];
sort?: string;
sort?: string[];
limit?: number;
page?: number;
};

View File

@@ -1,7 +1,6 @@
import { defineLayout } from '@directus/shared/utils';
import MapLayout from './map.vue';
import MapOptions from './options.vue';
import MapSidebar from './sidebar.vue';
import MapActions from './actions.vue';
import { useI18n } from 'vue-i18n';
@@ -28,7 +27,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
component: MapLayout,
slots: {
options: MapOptions,
sidebar: MapSidebar,
// eslint-disable-next-line @typescript-eslint/no-empty-function
sidebar: () => {},
actions: MapActions,
},
setup(props, { emit }) {
@@ -39,15 +39,25 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
const selection = useSync(props, 'selection', emit);
const layoutOptions = useSync(props, 'layoutOptions', emit);
const layoutQuery = useSync(props, 'layoutQuery', emit);
const filters = useSync(props, 'filters', emit);
const searchQuery = useSync(props, 'searchQuery', emit);
const { collection } = toRefs(props);
const { collection, filter, filterUser, search } = toRefs(props);
const { info, primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
const page = syncOption(layoutQuery, 'page', 1);
const limit = syncOption(layoutQuery, 'limit', 1000);
const sort = syncOption(layoutQuery, 'sort', fieldsInCollection.value?.[0]?.field);
const sort = syncOption(layoutQuery, 'sort', [fieldsInCollection.value?.[0]?.field]);
const locationFilter = ref<Filter>();
const filterWithLocation = computed<Filter | null>(() => {
if (!locationFilter.value) return filter.value;
if (!filter.value) return locationFilter.value;
return {
_and: [filter.value, locationFilter.value],
};
});
const customLayerDrawerOpen = ref(false);
@@ -123,10 +133,12 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
const locationFilterOutdated = ref(false);
function getLocationFilter(): Filter | undefined {
if (!isGeometryFieldNative.value || !cameraOptions.value) {
if (!isGeometryFieldNative.value || !cameraOptions.value || !geometryField.value) {
return;
}
const bbox = cameraOptions.value.bbox;
const bboxPolygon = [
[bbox[0], bbox[1]],
[bbox[2], bbox[1]],
@@ -134,35 +146,36 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
[bbox[0], bbox[3]],
[bbox[0], bbox[1]],
];
return {
key: 'location-filter',
field: geometryField.value,
operator: 'intersects_bbox',
value: {
type: 'Polygon',
coordinates: [bboxPolygon],
} as any,
[geometryField.value]: {
_intersects_bbox: {
type: 'Polygon',
coordinates: [bboxPolygon],
},
},
} as Filter;
}
function updateLocationFilter() {
const locationFilter = getLocationFilter();
locationFilterOutdated.value = false;
filters.value = filters.value.filter((filter) => filter.key !== 'location-filter').concat(locationFilter ?? []);
locationFilter.value = getLocationFilter();
}
function clearLocationFilter() {
shouldUpdateCamera.value = true;
locationFilterOutdated.value = false;
filters.value = filters.value.filter((filter) => filter.key !== 'location-filter');
locationFilter.value = undefined;
if (geojson.value) {
geojsonBounds.value = geojson.value.bbox;
}
}
function clearDataFilters() {
filters.value = filters.value.filter((filter) => filter.key === 'location-filter');
searchQuery.value = null;
locationFilter.value = undefined;
search.value = null;
}
const shouldUpdateCamera = ref(false);
@@ -189,8 +202,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
sort,
limit,
page,
filters,
searchQuery,
search,
filter: filterWithLocation,
fields: queryFields,
});
@@ -199,11 +212,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
const geojsonError = ref<string | null>(null);
const geojsonLoading = ref(false);
watch(() => searchQuery.value, onQueryChange);
watch(() => collection.value, onQueryChange);
watch(() => limit.value, onQueryChange);
watch(() => sort.value, onQueryChange);
watch(() => items.value, updateGeojson);
watch([search, collection, limit, sort], onQueryChange);
watch(items, updateGeojson);
watch(
() => geometryField.value,
@@ -291,7 +301,7 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
});
const showingCount = computed(() => {
if ((itemCount.value || 0) < (totalCount.value || 0)) {
if ((itemCount.value || 0) < (totalCount.value || 0) && filterUser.value) {
if (itemCount.value === 1) {
return t('one_filtered_item');
}
@@ -311,10 +321,6 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
});
});
const activeFilterCount = computed(() => {
return filters.value.filter((filter) => !filter.locked).length;
});
return {
template,
geojson,
@@ -345,12 +351,11 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
itemCount,
fieldsInCollection,
limit,
filters,
filter,
primaryKeyField,
sort,
info,
showingCount,
activeFilterCount,
refresh,
resetPresetAndRefresh,
geometryFields,
@@ -359,6 +364,7 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
updateLocationFilter,
clearLocationFilter,
clearDataFilters,
locationFilter,
};
async function resetPresetAndRefresh() {

View File

@@ -47,18 +47,15 @@
</v-info>
<v-progress-circular v-else-if="loading || geojsonLoading" indeterminate x-large class="center" />
<v-info
v-else-if="itemCount === 0 && (searchQuery || activeFilterCount > 0 || !locationFilterOutdated)"
v-else-if="itemCount === 0 && (search || filter || !locationFilterOutdated)"
icon="search"
center
:title="t('no_results_here')"
:title="t('no_results')"
>
<template #append>
<v-card-actions>
<v-button
:disabled="!searchQuery && !filters.filter((f) => f.key !== 'location-filter').length"
@click="clearDataFilters"
>
{{ t('clear_data_filters') }}
<v-button :disabled="!search && !locationFilter" @click="clearDataFilters">
{{ t('layouts.map.clear_data_filter') }}
</v-button>
<v-button :disabled="locationFilterOutdated" @click="clearLocationFilter">
{{ t('layouts.map.clear_location_filter') }}
@@ -128,7 +125,7 @@ export default defineComponent({
type: Array as PropType<Item[]>,
default: () => [],
},
searchQuery: {
search: {
type: String as PropType<string | null>,
default: null,
},
@@ -192,10 +189,6 @@ export default defineComponent({
type: Number,
default: null,
},
activeFilterCount: {
type: Number,
required: true,
},
totalPages: {
type: Number,
required: true,
@@ -212,10 +205,6 @@ export default defineComponent({
type: Number,
required: true,
},
filters: {
type: Array as PropType<Filter[]>,
required: true,
},
autoLocationFilter: {
type: Boolean,
default: undefined,
@@ -240,8 +229,16 @@ export default defineComponent({
type: Boolean,
required: true,
},
filter: {
type: Object as PropType<Filter>,
default: null,
},
locationFilter: {
type: Object as PropType<Filter>,
default: null,
},
},
emits: ['update:cameraOptions', 'update:limit'],
emits: ['update:cameraOptions', 'update:limit', 'update:locationFilterOutdated'],
setup(props, { emit }) {
const { t, n } = useI18n();

View File

@@ -1,49 +0,0 @@
<template>
<filter-sidebar-detail v-model="filtersWritable" :collection="collection" :loading="loading" />
<export-sidebar-detail
:layout-query="layoutQuery"
:filters="filters"
:search-query="searchQuery"
:collection="collection"
/>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { LayoutQuery } from './types';
import { AppFilter } from '@directus/shared/types';
import { useSync } from '@directus/shared/composables';
export default defineComponent({
inheritAttrs: false,
props: {
collection: {
type: String,
required: true,
},
layoutQuery: {
type: Object as PropType<LayoutQuery>,
default: () => ({}),
},
filters: {
type: Array as PropType<AppFilter[]>,
default: () => [],
},
searchQuery: {
type: String as PropType<string | null>,
default: null,
},
loading: {
type: Boolean,
required: true,
},
},
emits: ['update:filters'],
setup(props, { emit }) {
const filtersWritable = useSync(props, 'filters', emit);
return { filtersWritable };
},
});
</script>

View File

@@ -3,7 +3,7 @@ import { GeometryFormat } from '@directus/shared/types';
export type LayoutQuery = {
fields: string[];
sort: string;
sort: string[];
limit: number;
page: number;
};

View File

@@ -1,7 +1,6 @@
import { defineLayout } from '@directus/shared/utils';
import TabularLayout from './tabular.vue';
import TabularOptions from './options.vue';
import TabularSidebar from './sidebar.vue';
import TabularActions from './actions.vue';
import { useI18n } from 'vue-i18n';
@@ -26,7 +25,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
component: TabularLayout,
slots: {
options: TabularOptions,
sidebar: TabularSidebar,
// eslint-disable-next-line @typescript-eslint/no-empty-function
sidebar: () => {},
actions: TabularActions,
},
setup(props, { emit }) {
@@ -39,10 +39,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
const selection = useSync(props, 'selection', emit);
const layoutOptions = useSync(props, 'layoutOptions', emit);
const layoutQuery = useSync(props, 'layoutQuery', emit);
const filters = useSync(props, 'filters', emit);
const searchQuery = useSync(props, 'searchQuery', emit);
const { collection } = toRefs(props);
const { collection, filter, filterUser, search } = toRefs(props);
const { info, primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection);
@@ -55,8 +53,8 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
limit,
page,
fields: fieldsWithRelational,
filters: filters,
searchQuery: searchQuery,
filter,
search,
}
);
@@ -64,19 +62,22 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
useTable();
const showingCount = computed(() => {
if ((itemCount.value || 0) < (totalCount.value || 0)) {
if ((itemCount.value || 0) < (totalCount.value || 0) && filterUser.value) {
if (itemCount.value === 1) {
return t('one_filtered_item');
}
return t('start_end_of_count_filtered_items', {
start: n((+page.value - 1) * limit.value + 1),
end: n(Math.min(page.value * limit.value, itemCount.value || 0)),
count: n(itemCount.value || 0),
});
}
if (itemCount.value === 1) {
return t('one_item');
}
return t('start_end_of_count_items', {
start: n((+page.value - 1) * limit.value + 1),
end: n(Math.min(page.value * limit.value, itemCount.value || 0)),
@@ -84,14 +85,6 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
});
});
const activeFilterCount = computed(() => {
let count = filters.value.filter((filter) => !filter.locked).length;
if (searchQuery.value && searchQuery.value.length > 0) count++;
return count;
});
const availableFields = computed(() => {
return fieldsInCollection.value.filter((field: Field) => field.meta?.special?.includes('no-data') !== true);
});
@@ -121,11 +114,12 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
sortField,
changeManualSort,
hideDragImage,
activeFilterCount,
refresh,
resetPresetAndRefresh,
selectAll,
availableFields,
filter,
search,
};
async function resetPresetAndRefresh() {
@@ -164,11 +158,11 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
},
});
const sort = computed({
const sort = computed<string[]>({
get() {
return layoutQuery.value?.sort || primaryKeyField.value?.field || '';
return layoutQuery.value?.sort || [primaryKeyField.value!.field] || [];
},
set(newSort: string) {
set(newSort: string[]) {
layoutQuery.value = {
...(layoutQuery.value || {}),
page: 1,
@@ -234,10 +228,10 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
function useTable() {
const tableSort = computed(() => {
if (sort.value?.startsWith('-')) {
return { by: sort.value.substring(1), desc: true };
if (sort.value?.[0].startsWith('-')) {
return { by: sort.value[0].substring(1), desc: true };
} else {
return { by: sort.value, desc: false };
return { by: sort.value[0], desc: false };
}
});
@@ -358,7 +352,7 @@ export default defineLayout<LayoutOptions, LayoutQuery>({
let sortString = newSort.by;
if (newSort.desc === true) sortString = '-' + sortString;
sort.value = sortString;
sort.value = [sortString];
}
function getFieldDisplay(fieldKey: string) {

View File

@@ -1,49 +0,0 @@
<template>
<filter-sidebar-detail v-model="filtersWritable" :collection="collection" :loading="loading" />
<export-sidebar-detail
:layout-query="layoutQuery"
:filters="filters"
:search-query="searchQuery"
:collection="collection"
/>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { useSync } from '@directus/shared/composables';
import { AppFilter } from '@directus/shared/types';
import { LayoutQuery } from './types';
export default defineComponent({
inheritAttrs: false,
props: {
collection: {
type: String,
required: true,
},
layoutQuery: {
type: Object as PropType<LayoutQuery>,
default: () => ({}),
},
filters: {
type: Array as PropType<AppFilter[]>,
default: () => [],
},
searchQuery: {
type: String as PropType<string | null>,
default: null,
},
loading: {
type: Boolean,
required: true,
},
},
emits: ['update:filters'],
setup(props, { emit }) {
const filtersWritable = useSync(props, 'filters', emit);
return { filtersWritable };
},
});
</script>

View File

@@ -74,7 +74,7 @@
</template>
</v-info>
<slot v-else-if="itemCount === 0 && activeFilterCount > 0" name="no-results" />
<slot v-else-if="itemCount === 0 && (filterUser || search)" name="no-results" />
<slot v-else-if="itemCount === 0" name="no-items" />
</div>
</template>
@@ -84,7 +84,7 @@ import { useI18n } from 'vue-i18n';
import { ComponentPublicInstance, defineComponent, PropType, ref } from 'vue';
import { useSync } from '@directus/shared/composables';
import useShortcut from '@/composables/use-shortcut';
import { Field, Item, Collection } from '@directus/shared/types';
import { Field, Item, Collection, Filter } from '@directus/shared/types';
import { HeaderRaw } from '@/components/v-table/types';
export default defineComponent({
@@ -174,10 +174,6 @@ export default defineComponent({
type: Function as PropType<(data: any) => Promise<void>>,
required: true,
},
activeFilterCount: {
type: Number,
required: true,
},
resetPresetAndRefresh: {
type: Function as PropType<() => Promise<void>>,
required: true,
@@ -186,6 +182,14 @@ export default defineComponent({
type: Function as PropType<() => void>,
required: true,
},
filterUser: {
type: Object as PropType<Filter>,
default: null,
},
search: {
type: String,
default: null,
},
},
emits: ['update:selection', 'update:tableHeaders', 'update:limit'],
setup(props, { emit }) {

View File

@@ -8,7 +8,7 @@ export type LayoutOptions = {
export type LayoutQuery = {
fields?: string[];
sort?: string;
sort?: string[];
page?: number;
limit?: number;
};

View File

@@ -1,6 +1,6 @@
<template>
<v-list large>
<v-list-item clickable :active="!activeFilter" @click="clearNavFilter">
<v-list-item clickable :active="!filterField" @click="clearNavFilter">
<v-list-item-icon>
<v-icon name="access_time" />
</v-list-item-icon>
@@ -11,7 +11,7 @@
<v-list-item
clickable
:active="activeFilter && activeFilter.field === 'user' && activeFilter.value === currentUserID"
:active="filterField === 'user' && filterValue === currentUserID"
@click="setNavFilter('user', currentUserID)"
>
<v-list-item-icon>
@@ -26,7 +26,7 @@
<v-list-item
clickable
:active="activeFilter && activeFilter.field === 'action' && activeFilter.value === 'create'"
:active="filterField === 'action' && filterValue === 'create'"
@click="setNavFilter('action', 'create')"
>
<v-list-item-icon>
@@ -39,7 +39,7 @@
<v-list-item
clickable
:active="activeFilter && activeFilter.field === 'action' && activeFilter.value === 'update'"
:active="filterField === 'action' && filterValue === 'update'"
@click="setNavFilter('action', 'update')"
>
<v-list-item-icon>
@@ -52,7 +52,7 @@
<v-list-item
clickable
:active="activeFilter && activeFilter.field === 'action' && activeFilter.value === 'delete'"
:active="filterField === 'action' && filterValue === 'delete'"
@click="setNavFilter('action', 'delete')"
>
<v-list-item-icon>
@@ -65,7 +65,7 @@
<v-list-item
clickable
:active="activeFilter && activeFilter.field === 'action' && activeFilter.value === 'comment'"
:active="filterField === 'action' && filterValue === 'comment'"
@click="setNavFilter('action', 'comment')"
>
<v-list-item-icon>
@@ -78,7 +78,7 @@
<v-list-item
clickable
:active="activeFilter && activeFilter.field === 'action' && activeFilter.value === 'login'"
:active="filterField === 'action' && filterValue === 'login'"
@click="setNavFilter('action', 'login')"
>
<v-list-item-icon>
@@ -95,51 +95,37 @@
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, PropType } from 'vue';
import { useUserStore } from '@/stores/user';
import { nanoid } from 'nanoid';
import { Filter } from '@directus/shared/types';
export default defineComponent({
props: {
filters: {
type: Array as PropType<Filter[]>,
required: true,
filter: {
type: Object as PropType<Filter>,
default: null,
},
},
emits: ['update:filters'],
emits: ['update:filter'],
setup(props, { emit }) {
const { t } = useI18n();
const userStore = useUserStore();
const currentUserID = computed(() => userStore.currentUser?.id);
const activeFilter = computed(() => {
return props.filters.find((filter) => filter.locked === true);
});
const filterField = computed(() => Object.keys(props.filter ?? {})[0] ?? null);
const filterValue = computed(() => Object.values(props.filter ?? {})[0]?._eq ?? null);
return { t, currentUserID, setNavFilter, clearNavFilter, activeFilter };
return { t, currentUserID, setNavFilter, clearNavFilter, filterField, filterValue };
function setNavFilter(key: string, value: any) {
emit('update:filters', [
...props.filters.filter((filter) => {
return filter.locked === false;
}),
{
key: nanoid(),
locked: true,
field: key,
operator: 'eq',
value: value,
emit('update:filter', {
[key]: {
_eq: value,
},
]);
});
}
function clearNavFilter() {
emit(
'update:filters',
props.filters.filter((filter) => {
return filter.locked === false;
})
);
emit('update:filter', null);
}
},
});

View File

@@ -4,8 +4,10 @@
v-slot="{ layoutState }"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
v-model:filters="filters"
v-model:search-query="searchQuery"
:filter="mergeFilters"
:filter-user="filter"
:filter-system="roleFilter"
:search="search"
collection="directus_activity"
>
<private-view :title="t('activity_feed')">
@@ -20,14 +22,26 @@
</template>
<template #actions>
<search-input v-model="searchQuery" />
<search-input v-model="search" v-model:filter="filter" collection="directus_activity" />
</template>
<template #navigation>
<activity-navigation v-model:filters="filters" />
<activity-navigation v-model:filter="roleFilter" />
</template>
<component :is="`layout-${layout}`" v-bind="layoutState" class="layout" />
<component :is="`layout-${layout}`" v-bind="layoutState" class="layout">
<template #no-results>
<v-info :title="t('no_results')" icon="search" center>
{{ t('no_results_copy') }}
</v-info>
</template>
<template #no-items>
<v-info :title="t('item_count', 0)" icon="access_time" center>
{{ t('no_items_copy') }}
</v-info>
</template>
</component>
<router-view name="detail" :primary-key="primaryKey" />
@@ -50,13 +64,14 @@ import { defineComponent, computed, ref } from 'vue';
import ActivityNavigation from '../components/navigation.vue';
import usePreset from '@/composables/use-preset';
import { useLayout } from '@/composables/use-layout';
import FilterSidebarDetail from '@/views/private/components/filter-sidebar-detail';
import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detail';
import SearchInput from '@/views/private/components/search-input';
import { Filter } from '@directus/shared/types';
import { mergeFilters } from '@directus/shared/utils';
export default defineComponent({
name: 'ActivityCollection',
components: { ActivityNavigation, FilterSidebarDetail, LayoutSidebarDetail, SearchInput },
components: { ActivityNavigation, LayoutSidebarDetail, SearchInput },
props: {
primaryKey: {
type: String,
@@ -66,12 +81,25 @@ export default defineComponent({
setup() {
const { t } = useI18n();
const { layout, layoutOptions, layoutQuery, filters, searchQuery } = usePreset(ref('directus_activity'));
const { layout, layoutOptions, layoutQuery, filter, search } = usePreset(ref('directus_activity'));
const { breadcrumb } = useBreadcrumb();
const { layoutWrapper } = useLayout(layout);
return { t, breadcrumb, layout, layoutWrapper, layoutOptions, layoutQuery, searchQuery, filters };
const roleFilter = ref<Filter | null>(null);
return {
t,
breadcrumb,
layout,
layoutWrapper,
layoutOptions,
layoutQuery,
search,
filter,
roleFilter,
mergeFilters,
};
function useBreadcrumb() {
const breadcrumb = computed(() => {

View File

@@ -6,8 +6,10 @@
v-model:selection="selection"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
v-model:filters="filters"
v-model:search-query="searchQuery"
:filter-user="filter"
:filter-system="archiveFilter"
:filter="mergeFilters(filter, archiveFilter)"
:search="search"
:collection="collection"
:reset-preset="resetPreset"
>
@@ -88,7 +90,7 @@
</template>
<template #actions>
<search-input v-model="searchQuery" />
<search-input v-model="search" v-model:filter="filter" :collection="collection" />
<v-dialog v-if="selection.length > 0" v-model="confirmDelete" @esc="confirmDelete = false">
<template #activator="{ on }">
@@ -235,6 +237,11 @@
</layout-sidebar-detail>
<component :is="`layout-sidebar-${layout || 'tabular'}`" v-bind="layoutState" />
<refresh-sidebar-detail v-model="refreshInterval" @refresh="refresh" />
<export-sidebar-detail
:collection="collection"
:filter="mergeFilters(filter, archiveFilter)"
:search="search"
/>
</template>
<v-dialog :model-value="deleteError !== null">
@@ -272,6 +279,7 @@ import { usePermissionsStore, useUserStore } from '@/stores';
import DrawerBatch from '@/views/private/components/drawer-batch';
import { unexpectedError } from '@/utils/unexpected-error';
import { getLayouts } from '@/layouts';
import { mergeFilters } from '@directus/shared/utils';
type Item = {
[field: string]: any;
@@ -299,6 +307,10 @@ export default defineComponent({
type: String,
default: null,
},
showArchive: {
type: Boolean,
default: false,
},
},
setup(props) {
const { t } = useI18n();
@@ -322,8 +334,8 @@ export default defineComponent({
layout,
layoutOptions,
layoutQuery,
filters,
searchQuery,
filter,
search,
savePreset,
bookmarkExists,
saveCurrentAsBookmark,
@@ -365,6 +377,32 @@ export default defineComponent({
const { batchEditAllowed, batchArchiveAllowed, batchDeleteAllowed, createAllowed } = usePermissions();
const archiveFilter = computed(() => {
if (!currentCollection.value?.meta) return null;
const field = currentCollection.value.meta.archive_field;
if (!field) return filter.value;
let archiveValue: any = currentCollection.value.meta.archive_value;
if (archiveValue === 'true') archiveValue = true;
if (archiveValue === 'false') archiveValue = false;
if (props.showArchive) {
return {
[field]: {
_eq: archiveValue,
},
};
} else {
return {
[field]: {
_neq: archiveValue,
},
};
}
});
return {
t,
addNewLink,
@@ -373,14 +411,14 @@ export default defineComponent({
confirmDelete,
currentCollection,
deleting,
filters,
filter,
layoutRef,
layoutWrapper,
selection,
layoutOptions,
layoutQuery,
layout,
searchQuery,
search,
savePreset,
bookmarkExists,
currentCollectionLink,
@@ -408,6 +446,8 @@ export default defineComponent({
refresh,
refreshInterval,
currentLayout,
archiveFilter,
mergeFilters,
};
async function refresh() {
@@ -476,11 +516,15 @@ export default defineComponent({
archiving.value = true;
let archiveValue: any = currentCollection.value.meta.archive_value;
if (archiveValue === 'true') archiveValue = true;
if (archiveValue === 'false') archiveValue = false;
try {
await api.patch(`/items/${props.collection}`, {
keys: selection.value,
data: {
[currentCollection.value.meta.archive_field]: currentCollection.value.meta.archive_value,
[currentCollection.value.meta.archive_field]: archiveValue,
},
});
@@ -543,8 +587,8 @@ export default defineComponent({
}
function clearFilters() {
filters.value = [];
searchQuery.value = null;
filter.value = null;
search.value = null;
}
function usePermissions() {

View File

@@ -8,7 +8,14 @@
<div class="folders">
<v-item-group v-model="openFolders" scope="files-navigation" multiple>
<v-list-group to="/files" value="root" scope="files-navigation" exact disable-groupable-parent>
<v-list-group
to="/files"
:active="currentFolder === null"
value="root"
scope="files-navigation"
exact
disable-groupable-parent
>
<template #activator>
<v-list-item-icon>
<v-icon name="folder_special" outline />

View File

@@ -6,8 +6,10 @@
v-model:selection="selection"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
v-model:filters="layoutFilters"
v-model:search-query="searchQuery"
:filter="mergeFilters(filter, folderTypeFilter)"
:filter-user="filter"
:filter-system="folderTypeFilter"
:search="search"
collection="directus_files"
:reset-preset="resetPreset"
>
@@ -27,7 +29,7 @@
</template>
<template #actions>
<search-input v-model="searchQuery" />
<search-input v-model="search" v-model:filter="filter" collection="directus_files" />
<add-folder :parent="folder" :disabled="createFolderAllowed !== true" />
@@ -114,7 +116,17 @@
<component :is="`layout-${layout}`" class="layout" v-bind="layoutState">
<template #no-results>
<v-info :title="t('no_results')" icon="search" center>
<v-info v-if="!filter && !search" :title="t('file_count', 0)" icon="folder" center>
{{ t('no_files_copy') }}
<template #append>
<v-button :to="folder ? { path: `/files/folders/${folder}/+` } : { path: '/files/+' }">
{{ t('add_file') }}
</v-button>
</template>
</v-info>
<v-info v-else :title="t('no_results')" icon="search" center>
{{ t('no_results_copy') }}
<template #append>
@@ -153,6 +165,11 @@
<component :is="`layout-options-${layout}`" v-bind="layoutState" />
</layout-sidebar-detail>
<component :is="`layout-sidebar-${layout}`" v-bind="layoutState" />
<export-sidebar-detail
collection="directus_files"
:filter="mergeFilters(filter, folderTypeFilter)"
:search="search"
/>
</template>
<template v-if="showDropEffect">
@@ -171,7 +188,6 @@ import { defineComponent, computed, ref, PropType, onMounted, onUnmounted, nextT
import FilesNavigation from '../components/navigation.vue';
import api from '@/api';
import usePreset from '@/composables/use-preset';
import FilterSidebarDetail from '@/views/private/components/filter-sidebar-detail';
import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detail';
import AddFolder from '../components/add-folder.vue';
import SearchInput from '@/views/private/components/search-input';
@@ -186,6 +202,8 @@ import { useLayout } from '@/composables/use-layout';
import uploadFiles from '@/utils/upload-files';
import { unexpectedError } from '@/utils/unexpected-error';
import DrawerBatch from '@/views/private/components/drawer-batch';
import { Filter } from '@directus/shared/types';
import { mergeFilters } from '@directus/shared/utils';
type Item = {
[field: string]: any;
@@ -195,7 +213,6 @@ export default defineComponent({
name: 'FilesCollection',
components: {
FilesNavigation,
FilterSidebarDetail,
LayoutSidebarDetail,
AddFolder,
SearchInput,
@@ -226,68 +243,56 @@ export default defineComponent({
const userStore = useUserStore();
const { layout, layoutOptions, layoutQuery, filters, searchQuery, resetPreset } = usePreset(ref('directus_files'));
const { layout, layoutOptions, layoutQuery, filter, search, resetPreset } = usePreset(ref('directus_files'));
const { confirmDelete, deleting, batchDelete, error: deleteError, batchEditActive } = useBatch();
const { breadcrumb, title } = useBreadcrumb();
const filtersWithFolderAndType = computed(() => {
const filtersParsed: any[] = [
{
locked: true,
field: 'type',
operator: 'nnull',
value: 1,
},
];
const folderTypeFilter = computed(() => {
const filterParsed: Filter = {
_and: [
{
type: {
_nnull: true,
},
},
],
};
if (props.special === null) {
if (props.folder !== null) {
filtersParsed.push({
locked: true,
operator: 'eq',
field: 'folder',
value: props.folder,
filterParsed._and.push({
folder: {
_eq: props.folder,
},
});
} else {
filtersParsed.push({
locked: true,
operator: 'null',
field: 'folder',
value: true,
filterParsed._and.push({
folder: {
_null: true,
},
});
}
}
if (props.special === 'mine' && userStore.currentUser) {
filtersParsed.push({
locked: true,
operator: 'eq',
field: 'uploaded_by',
value: userStore.currentUser.id,
filterParsed._and.push({
uploaded_by: {
_eq: userStore.currentUser.id,
},
});
}
if (props.special === 'recent') {
filtersParsed.push({
locked: true,
operator: 'gt',
field: 'uploaded_on',
value: subDays(new Date(), 5).toISOString(),
filterParsed._and.push({
uploaded_on: {
_gt: subDays(new Date(), 5).toISOString(),
},
});
}
return filtersParsed;
});
const layoutFilters = computed<any[]>({
get() {
return [...filters.value, ...filtersWithFolderAndType.value];
},
set(newFilters) {
filters.value = newFilters;
},
return filterParsed;
});
const { layoutWrapper } = useLayout(layout);
@@ -312,13 +317,12 @@ export default defineComponent({
title,
layoutRef,
layoutWrapper,
layoutFilters,
selection,
layoutOptions,
layoutQuery,
layout,
filtersWithFolderAndType,
searchQuery,
folderTypeFilter,
search,
moveToDialogActive,
moveToFolder,
moving,
@@ -340,6 +344,8 @@ export default defineComponent({
batchDelete,
deleteError,
batchEditActive,
filter,
mergeFilters,
};
function useBatch() {
@@ -455,8 +461,8 @@ export default defineComponent({
}
function clearFilters() {
filters.value = [];
searchQuery.value = null;
filter.value = null;
search.value = null;
}
function usePermissions() {

View File

@@ -4,8 +4,8 @@
v-slot="{ layoutState }"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
v-model:filters="layoutFilters"
v-model:search-query="searchQuery"
:filter="layoutFilter"
:search="search"
:collection="values.collection"
readonly
>
@@ -66,7 +66,12 @@
<v-form v-model="edits" :fields="fields" :loading="loading" :initial-values="initialValues" :primary-key="id" />
<div class="layout">
<component :is="`layout-${values.layout}`" v-if="values.layout && values.collection" v-bind="layoutState">
<component
:is="`layout-${values.layout}`"
v-if="values.layout && values.collection"
v-bind="layoutState"
:collection="values.collection"
>
<template #no-results>
<v-info :title="t('no_results')" icon="search" center>
{{ t('no_results_copy') }}
@@ -93,7 +98,7 @@
<div class="layout-sidebar">
<sidebar-detail icon="search" :title="t('search')">
<v-input v-model="searchQuery" :placeholder="t('preset_search_placeholder')"></v-input>
<v-input v-model="search" :placeholder="t('preset_search_placeholder')"></v-input>
</sidebar-detail>
<component
@@ -156,7 +161,7 @@ type FormattedPreset = {
layout_query: Record<string, any> | null;
layout_options: Record<string, any> | null;
filters: readonly Filter[] | null;
filter: Filter | null;
};
export default defineComponent({
@@ -181,14 +186,13 @@ export default defineComponent({
const { loading, preset } = usePreset();
const { fields } = useForm();
const { edits, hasEdits, initialValues, values, layoutQuery, layoutOptions, updateFilters, searchQuery } =
useValues();
const { edits, hasEdits, initialValues, values, layoutQuery, layoutOptions, updateFilters, search } = useValues();
const { save, saving } = useSave();
const { deleting, deleteAndQuit, confirmDelete } = useDelete();
const layoutFilters = computed<any>({
const layoutFilter = computed<any>({
get() {
return values.value.filters || [];
return values.value.filter ?? null;
},
set(newFilters) {
updateFilters(newFilters);
@@ -237,13 +241,13 @@ export default defineComponent({
layoutWrapper,
layoutQuery,
layoutOptions,
layoutFilters,
layoutFilter,
hasEdits,
deleting,
deleteAndQuit,
confirmDelete,
updateFilters,
searchQuery,
search,
isSavable,
confirmLeave,
leaveTo,
@@ -266,7 +270,7 @@ export default defineComponent({
if (edits.value.layout) editsParsed.layout = edits.value.layout;
if (edits.value.layout_query) editsParsed.layout_query = edits.value.layout_query;
if (edits.value.layout_options) editsParsed.layout_options = edits.value.layout_options;
if (edits.value.filters) editsParsed.filters = edits.value.filters;
if (edits.value.filter) editsParsed.filter = edits.value.filter;
editsParsed.search = edits.value.search;
if (edits.value.scope) {
@@ -334,7 +338,7 @@ export default defineComponent({
scope: 'all',
layout_query: null,
layout_options: null,
filters: null,
filter: null,
};
if (isNew.value === true) return defaultValues;
if (preset.value === null) return defaultValues;
@@ -357,7 +361,7 @@ export default defineComponent({
scope: scope,
layout_query: preset.value.layout_query,
layout_options: preset.value.layout_options,
filters: preset.value.filters,
filter: preset.value.filter,
};
return value;
@@ -406,7 +410,7 @@ export default defineComponent({
},
});
const searchQuery = computed<string | null>({
const search = computed<string | null>({
get() {
return values.value.search;
},
@@ -418,12 +422,12 @@ export default defineComponent({
},
});
return { edits, initialValues, values, layoutQuery, layoutOptions, hasEdits, updateFilters, searchQuery };
return { edits, initialValues, values, layoutQuery, layoutOptions, hasEdits, updateFilters, search };
function updateFilters(newFilters: Filter) {
function updateFilters(newFilter: Filter) {
edits.value = {
...edits.value,
filters: newFilters,
filter: newFilter,
};
}
}

View File

@@ -9,11 +9,7 @@
}}
</v-notice>
<interface-system-filter
:value="permissions"
:collection-name="permission.collection"
@input="permissions = $event"
/>
<v-form v-model="permissionSync" :fields="fields" />
<div v-if="appMinimal" class="app-minimal">
<v-divider />
@@ -48,21 +44,23 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const internalPermission = useSync(props, 'permission', emit);
const permissionSync = useSync(props, 'permission', emit);
const permissions = computed({
get() {
return internalPermission.value.permissions;
const fields = computed(() => [
{
field: 'permissions',
name: t('rule'),
type: 'json',
meta: {
interface: 'system-filter',
options: {
collectionName: permissionSync.value.collection,
},
},
},
set(newPermissions: Record<string, any> | null) {
internalPermission.value = {
...internalPermission.value,
permissions: newPermissions,
};
},
});
]);
return { t, permissions };
return { t, fields, permissionSync };
},
});
</script>

View File

@@ -9,11 +9,7 @@
}}
</v-notice>
<interface-system-filter
:value="validation"
:collection-name="permission.collection"
@input="validation = $event"
/>
<v-form v-model="permissionSync" :fields="fields" />
</div>
</template>
@@ -38,21 +34,23 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const internalPermission = useSync(props, 'permission', emit);
const permissionSync = useSync(props, 'permission', emit);
const validation = computed({
get() {
return internalPermission.value.validation;
const fields = computed(() => [
{
field: 'permissions',
name: t('rule'),
type: 'json',
meta: {
interface: 'system-filter',
options: {
collectionName: permissionSync.value.collection,
},
},
},
set(newValidation: Record<string, any> | null) {
internalPermission.value = {
...internalPermission.value,
validation: newValidation,
};
},
});
]);
return { t, validation };
return { t, permissionSync, fields };
},
});
</script>

View File

@@ -6,8 +6,8 @@
v-model:selection="selection"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
v-model:filters="filters"
v-model:search-query="searchQuery"
:filter="filter"
:search="search"
collection="directus_webhooks"
>
<private-view :title="t('webhooks')">
@@ -24,7 +24,7 @@
</template>
<template #actions>
<search-input v-model="searchQuery" />
<search-input v-model="search" collection="directus_webhooks" />
<v-dialog v-if="selection.length > 0" v-model="confirmDelete" @esc="confirmDelete = false">
<template #activator="{ on }">
@@ -121,7 +121,7 @@ export default defineComponent({
const layoutRef = ref();
const selection = ref<Item[]>([]);
const { layout, layoutOptions, layoutQuery, filters, searchQuery } = usePreset(ref('directus_webhooks'));
const { layout, layoutOptions, layoutQuery, filter, search } = usePreset(ref('directus_webhooks'));
const { addNewLink, batchLink } = useLinks();
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
@@ -136,12 +136,12 @@ export default defineComponent({
deleting,
layoutRef,
layoutWrapper,
filters,
filter,
selection,
layoutOptions,
layoutQuery,
layout,
searchQuery,
search,
clearFilters,
};
@@ -186,8 +186,8 @@ export default defineComponent({
}
function clearFilters() {
filters.value = [];
searchQuery.value = null;
filter.value = null;
search.value = null;
}
},
});

View File

@@ -6,8 +6,10 @@
v-model:selection="selection"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
v-model:filters="layoutFilters"
v-model:search-query="searchQuery"
:filter="mergeFilters(filter, roleFilter)"
:filter-user="filter"
:filter-system="roleFilter"
:search="search"
collection="directus_users"
:reset-preset="resetPreset"
>
@@ -27,7 +29,7 @@
</template>
<template #actions>
<search-input v-model="searchQuery" />
<search-input v-model="search" v-model:filter="filter" collection="directus_users" />
<v-dialog v-if="selection.length > 0" v-model="confirmDelete" @esc="confirmDelete = false">
<template #activator="{ on }">
@@ -99,7 +101,17 @@
<component :is="`layout-${layout}`" class="layout" v-bind="layoutState">
<template #no-results>
<v-info :title="t('no_results')" icon="search" center>
<v-info v-if="!filter && !search" :title="t('user_count', 0)" icon="people_alt" center>
{{ t('no_users_copy') }}
<template v-if="canInviteUsers" #append>
<v-button :to="role ? { path: `/users/roles/${role}/+` } : { path: '/users/+' }">
{{ t('create_user') }}
</v-button>
</template>
</v-info>
<v-info v-else :title="t('no_results')" icon="search" center>
{{ t('no_results_copy') }}
<template #append>
@@ -136,6 +148,11 @@
<component :is="`layout-options-${layout}`" v-bind="layoutState" />
</layout-sidebar-detail>
<component :is="`layout-sidebar-${layout}`" v-bind="layoutState" />
<export-sidebar-detail
collection="directus_users"
:filter="mergeFilters(filter, roleFilter)"
:search="search"
/>
</template>
</private-view>
</component>
@@ -156,6 +173,7 @@ import useNavigation from '../composables/use-navigation';
import { useLayout } from '@/composables/use-layout';
import DrawerBatch from '@/views/private/components/drawer-batch';
import { Role } from '@directus/shared/types';
import { mergeFilters } from '@directus/shared/utils';
type Item = {
[field: string]: any;
@@ -181,31 +199,27 @@ export default defineComponent({
const layoutRef = ref();
const selection = ref<Item[]>([]);
const { layout, layoutOptions, layoutQuery, filters, searchQuery, resetPreset } = usePreset(ref('directus_users'));
const { layout, layoutOptions, layoutQuery, filter, search, resetPreset } = usePreset(ref('directus_users'));
const { addNewLink } = useLinks();
const { confirmDelete, deleting, batchDelete, error: deleteError, batchEditActive } = useBatch();
const { breadcrumb, title } = useBreadcrumb();
const layoutFilters = computed({
get() {
if (props.role !== null) {
const roleFilter = {
locked: true,
operator: 'eq',
field: 'role',
value: props.role,
};
const roleFilter = computed(() => {
if (props.role !== null) {
return {
_and: [
{
role: {
_eq: props.role,
},
},
],
};
}
return [roleFilter, ...filters.value];
}
return filters.value;
},
set(newFilters) {
filters.value = newFilters;
},
return null;
});
const canInviteUsers = computed(() => {
@@ -236,11 +250,10 @@ export default defineComponent({
layoutRef,
layoutWrapper,
selection,
layoutFilters,
layoutOptions,
layoutQuery,
layout,
searchQuery,
search,
clearFilters,
userInviteModalActive,
refresh,
@@ -253,6 +266,9 @@ export default defineComponent({
batchDelete,
deleteError,
batchEditActive,
filter,
roleFilter,
mergeFilters,
};
async function refresh() {
@@ -320,8 +336,8 @@ export default defineComponent({
}
function clearFilters() {
filters.value = [];
searchQuery.value = null;
filter.value = null;
search.value = null;
}
function usePermissions() {

View File

@@ -88,11 +88,9 @@ export default definePanel({
type: 'json',
name: '$t:filter',
meta: {
interface: 'code',
note: '[Learn More: Filter Rules](/admin/docs/reference/filter-rules)',
interface: 'system-filter',
options: {
language: 'json',
placeholder: '{\n\t<field>: {\n\t\t<operator>: <value>\n\t}\n}',
collectionField: 'collection',
},
},
},

View File

@@ -107,11 +107,9 @@ export default definePanel({
type: 'json',
name: '$t:filter',
meta: {
interface: 'code',
note: '[Learn More: Filter Rules](/admin/docs/reference/filter-rules)',
interface: 'system-filter',
options: {
language: 'json',
placeholder: '{\n\t<field>: {\n\t\t<operator>: <value>\n\t}\n}',
collectionField: 'collection',
},
},
},

View File

@@ -248,11 +248,9 @@ export default definePanel({
type: 'json',
name: '$t:filter',
meta: {
interface: 'code',
note: '[Learn More: Filter Rules](/admin/docs/reference/filter-rules)',
interface: 'system-filter',
options: {
language: 'json',
placeholder: '{\n\t<field>: {\n\t\t<operator>: <value>\n\t}\n}',
collectionField: 'collection',
},
},
},

View File

@@ -10,7 +10,7 @@ const defaultPreset: Omit<Preset, 'collection'> = {
role: null,
user: null,
search: null,
filters: null,
filter: null,
layout: null,
layout_query: null,
layout_options: null,
@@ -23,7 +23,7 @@ const systemDefaults: Record<string, Partial<Preset>> = {
layout: 'cards',
layout_query: {
cards: {
sort: '-uploaded_on',
sort: ['-uploaded_on'],
},
},
layout_options: {
@@ -41,7 +41,7 @@ const systemDefaults: Record<string, Partial<Preset>> = {
layout: 'cards',
layout_query: {
cards: {
sort: 'email',
sort: ['email'],
},
},
layout_options: {
@@ -58,7 +58,7 @@ const systemDefaults: Record<string, Partial<Preset>> = {
layout: 'tabular',
layout_query: {
tabular: {
sort: '-timestamp',
sort: ['-timestamp'],
fields: ['action', 'collection', 'timestamp', 'user'],
},
},

View File

@@ -15,6 +15,11 @@ export const useRequestsStore = defineStore({
startRequest() {
const id = nanoid();
this.queue = [...this.queue, id];
// If requests take more than 3.5 seconds, we'll have to assume they'll either never
// happen, or already crashed
setTimeout(() => this.endRequest(id), 3500);
return id;
},
endRequest(id: string) {

View File

@@ -5,8 +5,8 @@
v-model:selection="layoutSelection"
v-model:layout-options="localOptions"
v-model:layout-query="localQuery"
v-model:filters="layoutFilters"
v-model:search-query="searchQuery"
:filter="layoutFilter"
:search="search"
:collection="collection"
select-mode
>
@@ -24,7 +24,7 @@
<template #actions:prepend><component :is="`layout-actions-${localLayout}`" v-bind="layoutState" /></template>
<template #actions>
<search-input v-model="searchQuery" />
<search-input v-model="search" v-model:filter="presetFilter" :collection="collection" />
<v-button v-tooltip.bottom="t('save')" icon rounded @click="save">
<v-icon name="check" />
@@ -72,12 +72,12 @@ export default defineComponent({
type: Boolean,
default: false,
},
filters: {
type: Array as PropType<Filter[]>,
default: () => [],
filter: {
type: Object as PropType<Filter>,
default: null,
},
},
emits: ['update:filters', 'update:active', 'input'],
emits: ['update:active', 'input'],
setup(props, { emit }) {
const { t } = useI18n();
@@ -88,7 +88,7 @@ export default defineComponent({
const { collection } = toRefs(props);
const { info: collectionInfo } = useCollection(collection);
const { layout, layoutOptions, layoutQuery, searchQuery } = usePreset(collection, ref(null), true);
const { layout, layoutOptions, layoutQuery, search, filter: presetFilter } = usePreset(collection, ref(null), true);
// This is a local copy of the layout. This means that we can sync it the layout without
// having use-preset auto-save the values
@@ -105,17 +105,22 @@ export default defineComponent({
},
});
const layoutFilters = computed<Filter[]>({
const { layoutWrapper } = useLayout(layout);
const layoutFilter = computed({
get() {
return props.filters;
if (!props.filter) return presetFilter.value;
if (!presetFilter.value) return props.filter;
return {
_and: [props.filter, presetFilter.value],
};
},
set(newFilters) {
emit('update:filters', newFilters);
set(newFilter: Filter | null) {
presetFilter.value = newFilter;
},
});
const { layoutWrapper } = useLayout(layout);
return {
t,
save,
@@ -123,12 +128,13 @@ export default defineComponent({
internalActive,
layoutWrapper,
layoutSelection,
layoutFilters,
layoutFilter,
localLayout,
localOptions,
localQuery,
collectionInfo,
searchQuery,
search,
presetFilter,
};
function useActiveState() {

View File

@@ -38,7 +38,6 @@ import { defineComponent, ref, PropType, computed } from 'vue';
import { Filter } from '@directus/shared/types';
import api from '@/api';
import { getRootPath } from '@/utils/get-root-path';
import { filtersToQuery } from '@directus/shared/utils';
import { useCollectionsStore } from '@/stores/';
type LayoutQuery = {
@@ -53,11 +52,11 @@ export default defineComponent({
type: Object as PropType<LayoutQuery>,
default: (): LayoutQuery => ({}),
},
filters: {
type: Array as PropType<Filter[]>,
default: () => [],
filter: {
type: Object as PropType<Filter>,
default: null,
},
searchQuery: {
search: {
type: String as PropType<string | null>,
default: null,
},
@@ -84,7 +83,7 @@ export default defineComponent({
const url = getRootPath() + endpoint;
let params: Record<string, unknown> = {
access_token: api.defaults.headers.Authorization.substring(7),
access_token: api.defaults.headers?.Authorization.substring(7),
export: format.value || 'json',
};
@@ -93,17 +92,14 @@ export default defineComponent({
if (props.layoutQuery?.fields) params.fields = props.layoutQuery.fields;
if (props.layoutQuery?.limit) params.limit = props.layoutQuery.limit;
if (props.searchQuery) params.search = props.searchQuery;
if (props.search) params.search = props.search;
if (props.filters?.length) {
params = {
...params,
...filtersToQuery(props.filters),
};
if (props.filter) {
params.filter = props.filter;
}
if (props.searchQuery) {
params.search = props.searchQuery;
if (props.search) {
params.search = props.search;
}
}

View File

@@ -1,178 +0,0 @@
<template>
<div class="field-filter">
<div class="header">
<div v-tooltip="filter.field.split('.').join(' → ')" class="name">
<span v-if="filter.field.includes('.')" class="relational-indicator"></span>
{{ name }}
</div>
<v-menu show-arrow :disabled="disabled">
<template #activator="{ toggle }">
<div v-tooltip.top="t('change_advanced_filter_operator')" class="operator" @click="toggle">
<span>{{ t(`operators.${activeOperator}`) }}</span>
<v-icon name="expand_more" />
</div>
</template>
<v-list>
<v-list-item
v-for="operator in filterOperators"
:key="operator"
:active="operator === activeOperator"
clickable
@click="activeOperator = operator"
>
<v-list-item-content>{{ t(`operators.${operator}`) }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<div class="spacer" />
<v-icon
v-tooltip.left="t('delete_advanced_filter')"
class="remove"
name="close"
clickable
@click="$emit('remove')"
/>
</div>
<div class="field">
<filter-input v-model="value" :field="field" :type="field.type" :operator="activeOperator" :disabled="disabled" />
</div>
</div>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, computed } from 'vue';
import { Filter } from '@directus/shared/types';
import { useFieldsStore } from '@/stores';
import { getFilterOperatorsForType } from '@directus/shared/utils';
import FilterInput from './filter-input.vue';
export default defineComponent({
components: { FilterInput },
props: {
filter: {
type: Object as PropType<Filter>,
required: true,
},
collection: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['remove', 'update'],
setup(props, { emit }) {
const { t } = useI18n();
const fieldsStore = useFieldsStore();
const activeOperator = computed({
get() {
return props.filter.operator;
},
set(newOperator) {
emit('update', { operator: newOperator });
},
});
const value = computed({
get() {
return props.filter.value;
},
set(newValue) {
emit('update', { value: newValue });
},
});
const name = computed<string>(() => {
return getNameForFieldKey(props.filter.field);
});
const field = computed(() => getFieldForKey(props.filter.field));
const filterOperators = computed(() => {
return getFilterOperatorsForType(field.value.type);
});
return { t, activeOperator, value, name, field, filterOperators };
function getFieldForKey(fieldKey: string) {
return fieldsStore.getField(props.collection, fieldKey);
}
function getNameForFieldKey(fieldKey: string) {
return getFieldForKey(fieldKey)?.name;
}
},
});
</script>
<style lang="scss" scoped>
.field-filter {
.header {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.name,
.operator {
overflow: hidden;
white-space: nowrap;
}
.name {
position: relative;
margin-right: 8px;
overflow: visible;
text-overflow: ellipsis;
.relational-indicator {
position: absolute;
top: -1px;
left: -10px;
color: var(--foreground-subdued);
}
}
.operator {
display: flex;
align-items: center;
color: var(--primary);
cursor: pointer;
span {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.spacer {
flex-grow: 1;
}
.remove {
--v-icon-color: var(--foreground-subdued);
flex-grow: 0;
flex-shrink: 0;
opacity: 0;
&:hover {
--v-icon-color: var(--danger);
}
}
&:hover {
.header .remove {
opacity: 1;
}
}
}
</style>

View File

@@ -1,30 +0,0 @@
<template>
<v-list-item v-if="field.children === undefined" clickable @click="$emit('add', field.key)">
<v-list-item-content>{{ field.name }}</v-list-item-content>
</v-list-item>
<v-list-group v-else :value="field.key" clickable @click="$emit('add', field.key)">
<template #activator>{{ field.name }}</template>
<field-list-item
v-for="childField in field.children"
:key="childField.field"
:field="childField"
@add="$emit('add', $event)"
/>
</v-list-group>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { FieldTree } from './types';
export default defineComponent({
name: 'FieldListItem',
props: {
field: {
type: Object as PropType<FieldTree>,
required: true,
},
},
emits: ['add'],
});
</script>

View File

@@ -1,185 +0,0 @@
<template>
<div class="filter-input">
<div v-if="['between', 'nbetween'].includes(operator)" class="between">
<div class="field">
<component
:is="interfaceComponent"
:type="type"
:value="csvValue[0]"
:placeholder="t('lower_limit')"
:allow-other="true"
:choices="choices"
autofocus
@input="setCSV(0, $event)"
/>
</div>
<div class="field">
<component
:is="interfaceComponent"
:type="type"
:value="csvValue[1]"
:placeholder="t('upper_limit')"
:allow-other="true"
:choices="choices"
autofocus
@input="setCSV(1, $event)"
/>
</div>
</div>
<div v-else-if="['in', 'nin'].includes(operator)" class="list">
<div v-for="(val, index) in csvValue" :key="index" class="field">
<component
:is="interfaceComponent"
:type="type"
:value="val"
:placeholder="t('enter_a_value')"
:disabled="disabled"
:allow-other="true"
:choices="choices"
autofocus
@input="setCSV(index, $event)"
/>
<small class="remove" @click="removeCSV(val)">
{{ t('remove') }}
</small>
</div>
<v-button outlined full-width dashed :disabled="disabled" @click="addCSV">
<v-icon name="add" />
{{ t('add_new') }}
</v-button>
</div>
<template v-else-if="['empty', 'nempty', 'null', 'nnull'].includes(operator) === false">
<component
:is="interfaceComponent"
:type="type"
:value="internalValue"
:placeholder="t('enter_a_value')"
:disabled="disabled"
:choices="choices"
:allow-other="true"
autofocus
@input="internalValue = $event"
/>
</template>
</div>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, computed } from 'vue';
import { FilterOperator, Type, Field } from '@directus/shared/types';
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
export default defineComponent({
props: {
modelValue: {
type: [String, Number, Boolean],
required: true,
},
type: {
type: String as PropType<Type>,
required: true,
},
operator: {
type: String as PropType<FilterOperator>,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
field: {
type: Object as PropType<Field>,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const { t } = useI18n();
const internalValue = computed<string | string[] | boolean | number>({
get() {
return props.modelValue;
},
set(newValue) {
emit('update:modelValue', newValue);
},
});
const csvValue = computed({
get() {
return typeof props.modelValue === 'string' ? props.modelValue.split(',') : [];
},
set(newVal: string[]) {
internalValue.value = newVal.join(',');
},
});
const choices = computed(() => {
if (!props.field) return null;
return props.field?.meta?.options?.choices || null;
});
const interfaceComponent = computed(() => {
if (choices.value) {
return 'interface-select-dropdown';
}
if (props.field.type === 'csv') {
return 'interface-input';
}
return `interface-${getDefaultInterfaceForType(props.type)}`;
});
return { t, internalValue, csvValue, setCSV, removeCSV, addCSV, interfaceComponent, choices };
function setCSV(index: number, value: string) {
const newValue = Object.assign([], csvValue.value, { [index]: value });
csvValue.value = newValue;
}
function removeCSV(val: string) {
csvValue.value = csvValue.value.filter((value) => value !== val);
}
function addCSV() {
csvValue.value = [...csvValue.value, ''];
}
},
});
</script>
<style lang="scss" scoped>
.v-input + .v-input,
.v-input + .v-button {
margin-top: 8px;
}
.v-input .v-icon {
--v-icon-color: var(--foreground-subdued);
}
.list .field {
margin-bottom: 12px;
}
.list .field .remove {
display: flex;
align-items: center;
float: right;
width: max-content;
margin-bottom: 12px;
color: var(--foreground-subdued);
cursor: pointer;
}
.list .field .remove:hover {
color: var(--danger);
}
.between .field:first-child {
margin-bottom: 12px;
}
</style>

View File

@@ -1,194 +0,0 @@
<template>
<sidebar-detail :badge="filters.length > 0 ? filters.length : null" icon="filter_list" :title="t('advanced_filter')">
<field-filter
v-for="filter in filters"
:key="filter.key"
:filter="filter"
:collection="collection"
:disabled="loading"
@update="updateFilter(filter.key, $event)"
@remove="removeFilter(filter.key)"
/>
<v-divider v-if="filters.length" />
<v-menu attached :disabled="loading">
<template #activator="{ toggle, active }">
<v-input
clickable
:class="{ active }"
readonly
:model-value="t('add_filter')"
:disabled="loading"
@click="toggle"
>
<template #prepend><v-icon name="add" /></template>
<template #append><v-icon name="expand_more" /></template>
</v-input>
</template>
<v-list @toggle="loadFieldRelations($event.value, 1)">
<field-list-item v-for="field in treeList" :key="field.field" :field="field" @add="addFilterForField" />
</v-list>
</v-menu>
<template v-if="showArchiveToggle">
<v-divider />
<v-checkbox v-model="archived" :label="t('show_archived_items')" />
</template>
</sidebar-detail>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, computed, ref, watch, toRefs } from 'vue';
import { Field, Filter } from '@directus/shared/types';
import { useFieldsStore } from '@/stores';
import FieldFilter from './field-filter.vue';
import { nanoid } from 'nanoid';
import { debounce } from 'lodash';
import FieldListItem from './field-list-item.vue';
import { useCollection } from '@directus/shared/composables';
import { getFilterOperatorsForType } from '@directus/shared/utils';
import { useFieldTree } from '@/composables/use-field-tree';
export default defineComponent({
components: { FieldFilter, FieldListItem },
props: {
modelValue: {
type: Array as PropType<Filter[]>,
required: true,
},
collection: {
type: String,
required: true,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const { t } = useI18n();
const fieldsStore = useFieldsStore();
const { collection } = toRefs(props);
const { info: collectionInfo } = useCollection(collection);
const { treeList, loadFieldRelations } = useFieldTree(collection);
const localFilters = ref<Filter[]>([]);
watch(
() => props.modelValue,
() => {
localFilters.value = props.modelValue;
},
{ immediate: true }
);
const syncWithProp = debounce(() => {
emit('update:modelValue', localFilters.value);
}, 850);
const filters = computed<Filter[]>({
get() {
return localFilters.value.filter((filter) => {
return filter.locked !== true;
});
},
set(newFilters) {
localFilters.value = newFilters;
syncWithProp();
},
});
const showArchiveToggle = computed(
() => !!collectionInfo.value?.meta?.archive_field && !!collectionInfo.value?.meta?.archive_app_filter
);
const archived = computed({
get() {
return (
props.modelValue.find((filter) => filter.locked === true && filter.key === 'hide-archived') === undefined
);
},
set(showArchived: boolean) {
if (!collectionInfo.value?.meta?.archive_field) return;
if (showArchived === false) {
emit('update:modelValue', [
...filters.value,
{
key: 'hide-archived',
field: collectionInfo.value.meta.archive_field,
operator: 'neq',
value: collectionInfo.value.meta.archive_value!,
locked: true,
},
]);
} else {
emit(
'update:modelValue',
filters.value.filter((filter) => filter.key !== 'hide-archived')
);
}
},
});
return {
t,
treeList,
addFilterForField,
filters,
removeFilter,
updateFilter,
showArchiveToggle,
archived,
loadFieldRelations,
};
function addFilterForField(fieldKey: string) {
const field = fieldsStore.getField(props.collection, fieldKey) as Field;
const defaultOperator = getFilterOperatorsForType(field.type)[0];
emit('update:modelValue', [
...props.modelValue,
{
key: nanoid(),
field: fieldKey,
operator: defaultOperator || 'contains',
value: field.type === 'boolean' ? true : '',
},
]);
}
function updateFilter(key: string, updates: Filter) {
filters.value = filters.value.map((filter) => {
if (filter.key === key) {
return { ...filter, ...updates };
}
return filter;
});
}
function removeFilter(key: string) {
filters.value = filters.value.filter((filter) => filter.key !== key);
}
},
});
</script>
<style lang="scss" scoped>
.v-divider {
margin: 16px 0;
}
.field-filter {
margin-bottom: 16px;
}
</style>

View File

@@ -1,4 +0,0 @@
import FilterSidebarDetail from './filter-sidebar-detail.vue';
export { FilterSidebarDetail };
export default FilterSidebarDetail;

View File

@@ -1,5 +0,0 @@
export type FieldTree = {
field: string;
name: string;
children?: FieldTree[];
};

View File

@@ -1,20 +1,52 @@
<template>
<div
v-click-outside="disable"
v-tooltip.bottom="active ? null : t('search')"
class="search-input"
:class="{ active, 'has-content': !!modelValue }"
@click="active = true"
>
<v-icon name="search" />
<input ref="input" :value="modelValue" :placeholder="t('search_items')" @input="emitValue" @paste="emitValue" />
<v-icon v-if="modelValue" class="empty" name="close" @click.stop="emptyAndClose" />
</div>
<v-badge bottom right class="search-badge" :value="activeFilterCount" :disabled="!activeFilterCount || filterActive">
<div
v-click-outside="{
handler: disable,
middleware: onClickOutside,
}"
class="search-input"
:class="{ active, 'filter-active': filterActive, 'has-content': !!modelValue, 'filter-border': filterBorder }"
@click="active = true"
>
<v-icon v-tooltip.bottom="active ? null : t('search')" name="search" class="icon-search" :clickable="!active" />
<input ref="input" :value="modelValue" :placeholder="t('search_items')" @input="emitValue" @paste="emitValue" />
<v-icon
v-if="modelValue"
clickable
class="icon-empty"
name="close"
@click.stop="$emit('update:modelValue', null)"
/>
<v-icon
v-tooltip.bottom="t('filter')"
clickable
class="icon-filter"
name="filter_list"
@click="filterActive = !filterActive"
/>
<transition-expand @beforeEnter="filterBorder = true" @afterLeave="filterBorder = false">
<div v-show="filterActive" class="filter">
<interface-system-filter
class="filter-input"
inline
:value="filter"
:collection-name="collection"
@input="$emit('update:filter', $event)"
/>
</div>
</transition-expand>
</div>
</v-badge>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, watch } from 'vue';
import { defineComponent, ref, watch, PropType, computed } from 'vue';
import { Filter } from '@directus/shared/types';
import { isObject } from 'lodash';
export default defineComponent({
props: {
@@ -22,14 +54,24 @@ export default defineComponent({
type: String,
default: null,
},
collection: {
type: String,
required: true,
},
filter: {
type: Object as PropType<Filter>,
default: null,
},
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'update:filter'],
setup(props, { emit }) {
const { t } = useI18n();
const input = ref<HTMLInputElement | null>(null);
const active = ref(props.modelValue !== null);
const filterActive = ref(false);
const filterBorder = ref(false);
watch(active, (newActive: boolean) => {
if (newActive === true && input.value !== null) {
@@ -37,15 +79,41 @@ export default defineComponent({
}
});
return { t, active, disable, input, emptyAndClose, emitValue };
const activeFilterCount = computed(() => {
if (!props.filter) return 0;
let filterOperators: string[] = [];
parseLevel(props.filter);
return filterOperators.length;
function parseLevel(level: Record<string, any>) {
for (const [key, value] of Object.entries(level)) {
if (key === '_and' || key === '_or') {
value.forEach(parseLevel);
} else if (key.startsWith('_')) {
filterOperators.push(key);
} else {
if (isObject(value)) {
parseLevel(value);
}
}
}
}
});
return { t, active, disable, input, emitValue, activeFilterCount, filterActive, onClickOutside, filterBorder };
function onClickOutside(e: { path: HTMLElement[] }) {
if (e.path.some((el) => el?.classList?.contains('v-menu-content'))) return false;
return true;
}
function disable() {
active.value = false;
}
function emptyAndClose() {
emit('update:modelValue', null);
active.value = false;
filterActive.value = false;
}
function emitValue() {
@@ -58,71 +126,119 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.search-badge {
--v-badge-background-color: var(--primary);
--v-badge-offset-y: 8px;
--v-badge-offset-x: 8px;
}
.search-input {
display: flex;
align-items: center;
width: 44px;
width: 72px;
max-width: 100%;
height: 44px;
overflow: hidden;
border: 2px solid var(--border-normal);
border-radius: calc(44px / 2);
cursor: pointer;
transition: width var(--slow) var(--transition);
transition: width var(--slow) var(--transition), border-bottom-left-radius var(--fast) var(--transition),
border-bottom-right-radius var(--fast) var(--transition);
.empty {
.icon-empty {
--v-icon-color: var(--foreground-subdued);
display: none;
margin-left: 8px;
&:hover {
--v-icon-color: var(--danger);
}
}
.icon-search,
.icon-filter {
--v-icon-color-hover: var(--primary);
}
.icon-search {
margin: 0 8px;
margin-right: 4px;
}
.icon-filter {
margin: 0 8px;
margin-left: 0;
}
&:hover {
border-color: var(--border-normal-alt);
}
&:focus,
&:focus-within {
border-color: var(--primary);
&.has-content {
width: 200px;
.icon-empty {
display: block;
}
.icon-filter {
margin-left: 0;
}
}
&.active {
width: 300px;
border-color: var(--border-normal);
.empty {
.icon-empty {
display: block;
}
}
&.has-content {
width: 140px;
&.filter-active {
width: 420px; // blaze it
&:focus,
&:focus-within {
width: 300px;
}
.empty {
display: block;
.icon-filter {
--v-icon-color: var(--primary);
}
}
}
input {
flex-grow: 1;
width: 0px;
height: 100%;
margin: 0;
padding: 0;
color: var(--foreground-normal);
background-color: var(--background-page);
border: none;
border-radius: 0;
&.filter-border {
padding-bottom: 2px;
border-bottom: none;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
transition: border-bottom-left-radius none, border-bottom-right-radius none;
&::placeholder {
color: var(--foreground-subdued);
&::after {
position: absolute;
bottom: 0px;
left: 0;
z-index: -1;
width: 100%;
height: 2px;
background-color: var(--border-subdued);
content: '';
pointer-events: none;
}
}
input {
flex-grow: 1;
width: 0px;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
color: var(--foreground-normal);
text-overflow: ellipsis;
background-color: var(--background-page);
border: none;
border-radius: 0;
&::placeholder {
color: var(--foreground-subdued);
}
}
}
@@ -132,8 +248,21 @@ input {
text-overflow: ellipsis;
}
.v-icon {
margin: 0 8px;
cursor: pointer;
.filter {
position: absolute;
top: 100%;
left: 0;
width: 100%;
padding: 0;
background-color: var(--background-subdued);
border: 2px solid var(--border-normal);
border-top: 0;
border-bottom-right-radius: 22px;
border-bottom-left-radius: 22px;
}
.filter-input {
// Use margin instead of padding to make sure transition expand takes it into account
margin: 10px 8px;
}
</style>