mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// ],
|
||||
// };
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -9,7 +9,7 @@ export type LayoutOptions = {
|
||||
|
||||
export type LayoutQuery = {
|
||||
fields?: string[];
|
||||
sort?: string;
|
||||
sort?: string[];
|
||||
limit?: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -3,7 +3,7 @@ import { GeometryFormat } from '@directus/shared/types';
|
||||
|
||||
export type LayoutQuery = {
|
||||
fields: string[];
|
||||
sort: string;
|
||||
sort: string[];
|
||||
limit: number;
|
||||
page: number;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export type LayoutOptions = {
|
||||
|
||||
export type LayoutQuery = {
|
||||
fields?: string[];
|
||||
sort?: string;
|
||||
sort?: string[];
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
import FilterSidebarDetail from './filter-sidebar-detail.vue';
|
||||
|
||||
export { FilterSidebarDetail };
|
||||
export default FilterSidebarDetail;
|
||||
@@ -1,5 +0,0 @@
|
||||
export type FieldTree = {
|
||||
field: string;
|
||||
name: string;
|
||||
children?: FieldTree[];
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user