mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Filter (#420)
* Add filter operators * Render drawer detail hr full width * Start on filter component * Add getRelationsForCollection method * Add all filter logic (no sorting yet) * Use correct border color for divider by default * Tweak operator translations * Add getField action to fields store * Fetch fieldsinfo in relations get for files * Use scoped operators, start on type based input * Add disabled option to v-badge * Globally register v-badge component * Add between filter support * Hide alias fields * Work debouncing magic * Add margin beneath filter blocks * Fix fetching filter count in use-items comp * Add relational field lookup and show tooltip with field name in filter section * Use filters on users / files * Allow double level filter
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
import VAvatar from './v-avatar/';
|
||||
import VBadge from './v-badge/';
|
||||
import VButton from './v-button/';
|
||||
import VBreadcrumb from './v-breadcrumb';
|
||||
import VCard, { VCardActions, VCardTitle, VCardSubtitle, VCardText } from './v-card';
|
||||
@@ -42,6 +43,7 @@ import VTabs, { VTab, VTabsItems, VTabItem } from './v-tabs/';
|
||||
import VTextarea from './v-textarea';
|
||||
|
||||
Vue.component('v-avatar', VAvatar);
|
||||
Vue.component('v-badge', VBadge);
|
||||
Vue.component('v-button', VButton);
|
||||
Vue.component('v-breadcrumb', VBreadcrumb);
|
||||
Vue.component('v-card', VCard);
|
||||
|
||||
@@ -18,14 +18,15 @@ You can set the color, background color and boder color with the `--v-badge-colo
|
||||
```
|
||||
|
||||
## Props
|
||||
| Prop | Description | Default |
|
||||
|--------------------------|-----------------------------------------------------------------------------|------------------------------------|
|
||||
| `value` | The value that will be displayed inside the badge Only 2 characters allowed)| `null` |
|
||||
| `dot` | Only will show a small dot without any content | `false` |
|
||||
| `bordered` | Shows a border arround the badge | `false` |
|
||||
| `left` | Aligns the badge on the left side | `false` |
|
||||
| `bottom` | Aligns the badge on the bottom side | `false` |
|
||||
| `icon` | Shows an icon instead of text | `null` |
|
||||
| Prop | Description | Default |
|
||||
|------------|------------------------------------------------------------------------------|---------|
|
||||
| `value` | The value that will be displayed inside the badge Only 2 characters allowed) | `null` |
|
||||
| `dot` | Only will show a small dot without any content | `false` |
|
||||
| `bordered` | Shows a border arround the badge | `false` |
|
||||
| `left` | Aligns the badge on the left side | `false` |
|
||||
| `bottom` | Aligns the badge on the bottom side | `false` |
|
||||
| `icon` | Shows an icon instead of text | `null` |
|
||||
| `disabled` | Don't render the badge | `false` |
|
||||
|
||||
## Slots
|
||||
N/A
|
||||
@@ -34,10 +35,10 @@ N/A
|
||||
N/A
|
||||
|
||||
## CSS Variables
|
||||
| Variable | Default |
|
||||
|-------------------------------------|-------------------------------------------------|
|
||||
| `--v-badge-color` | `var(--white)` |
|
||||
| `--v-badge-background-color` | `var(--danger)` |
|
||||
| `--v-badge-border-color` | `var(--background-page)` |
|
||||
| `--v-badge-offset-x` | `0px` |
|
||||
| `--v-badge-offset-y` | `0px` |
|
||||
| Variable | Default |
|
||||
|------------------------------|--------------------------|
|
||||
| `--v-badge-color` | `var(--white)` |
|
||||
| `--v-badge-background-color` | `var(--danger)` |
|
||||
| `--v-badge-border-color` | `var(--background-page)` |
|
||||
| `--v-badge-offset-x` | `0px` |
|
||||
| `--v-badge-offset-y` | `0px` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="v-badge" :class="{ dot, bordered }">
|
||||
<span class="badge" :class="{ dot, bordered, left, bottom }">
|
||||
<span v-if="!disabled" class="badge" :class="{ dot, bordered, left, bottom }">
|
||||
<v-icon v-if="icon" :name="icon" :color="color" x-small />
|
||||
<span v-else>{{ value }}</span>
|
||||
</span>
|
||||
@@ -38,6 +38,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-divider {
|
||||
--v-divider-color: var(--border-subdued);
|
||||
--v-divider-color: var(--border-normal);
|
||||
--v-divider-label-color: var(--foreground-subdued);
|
||||
|
||||
display: flex;
|
||||
|
||||
@@ -156,7 +156,7 @@ export default defineComponent({
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
width: 80%; // allows flex to shrink to allow for slots
|
||||
width: 100px; // allows flex to shrink to allow for slots
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, Ref } from '@vue/composition-api';
|
||||
import useCollectionsStore from '@/stores/collections';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
|
||||
export function useCollection(collection: Ref<string>) {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
@@ -12,8 +13,8 @@ export function useCollection(collection: Ref<string>) {
|
||||
);
|
||||
});
|
||||
|
||||
const fields = computed(() => {
|
||||
return fieldsStore.state.fields.filter((field) => field.collection === collection.value);
|
||||
const fields = computed<Field[]>(() => {
|
||||
return fieldsStore.getFieldsForCollection(collection.value);
|
||||
});
|
||||
|
||||
const primaryKeyField = computed(() => {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
getItems();
|
||||
});
|
||||
|
||||
watch([page, limit, fields], async (after, before) => {
|
||||
watch([page, fields], async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
}
|
||||
});
|
||||
|
||||
watch([filters], async (after, before) => {
|
||||
watch([filters, limit], async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
/**
|
||||
* @NOTE
|
||||
*
|
||||
* When the filters change, we have to re-calculate the total amount of items too, as the
|
||||
* total amount of available items is based on the filter query.
|
||||
* When the filters or items per page change, we have to re-calculate the total amount of
|
||||
* items too, as the total amount of available items and pages is based on the two.
|
||||
*/
|
||||
itemCount.value = null;
|
||||
|
||||
@@ -170,6 +170,7 @@ export function useItems(collection: Ref<string>, options: Options) {
|
||||
limit: 0,
|
||||
fields: primaryKeyField.value.field,
|
||||
meta: 'filter_count',
|
||||
...filtersToQuery(filters.value),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -195,6 +195,29 @@
|
||||
"unmanaged_collections": "Unmanaged Collections",
|
||||
"system_collections": "System Collections",
|
||||
|
||||
"filter": "Filter",
|
||||
"advanced_filter": "Advanced Filter",
|
||||
"operators": {
|
||||
"eq": "Equals",
|
||||
"neq": "Doesn't equal",
|
||||
"lt": "Less than",
|
||||
"gt": "Greater than",
|
||||
"lte": "Less than or equal to",
|
||||
"gte": "Greater than or equal to",
|
||||
"in": "Is one of",
|
||||
"nin": "Is not one of",
|
||||
"null": "Is null",
|
||||
"nnull": "Isn't null",
|
||||
"contains": "Contains",
|
||||
"ncontains": "Doesn't contain",
|
||||
"between": "Is between",
|
||||
"nbetween": "Isn't between",
|
||||
"empty": "Is empty",
|
||||
"nempty": "Isn't empty",
|
||||
"all": "Contains these keys",
|
||||
"has": "Contains some of these keys"
|
||||
},
|
||||
|
||||
"about_directus": "About Directus",
|
||||
"activity_log": "Activity Log",
|
||||
"add_field_filter": "Add a field filter",
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #drawer><portal-target name="drawer" /></template>
|
||||
<template #drawer>
|
||||
<filter-drawer-detail v-model="filters" :collection="collection" />
|
||||
<portal-target name="drawer" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-dialog v-model="confirmDelete">
|
||||
@@ -57,6 +60,7 @@
|
||||
:selection.sync="selection"
|
||||
:view-options.sync="viewOptions"
|
||||
:view-query.sync="viewQuery"
|
||||
:filters.sync="filters"
|
||||
/>
|
||||
</private-view>
|
||||
</template>
|
||||
@@ -68,12 +72,12 @@ import CollectionsNavigation from '../../components/navigation/';
|
||||
import useCollectionsStore from '@/stores/collections';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import { i18n } from '@/lang';
|
||||
import api from '@/api';
|
||||
import { LayoutComponent } from '@/layouts/types';
|
||||
import CollectionsNotFound from '../not-found/';
|
||||
import useCollection from '@/compositions/use-collection';
|
||||
import useCollectionPreset from '@/compositions/use-collection-preset';
|
||||
import FilterDrawerDetail from '@/views/private/components/filter-drawer-detail';
|
||||
|
||||
const redirectIfNeeded: NavigationGuard = async (to, from, next) => {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
@@ -111,7 +115,7 @@ export default defineComponent({
|
||||
beforeRouteEnter: redirectIfNeeded,
|
||||
beforeRouteUpdate: redirectIfNeeded,
|
||||
name: 'collections-browse',
|
||||
components: { CollectionsNavigation, CollectionsNotFound },
|
||||
components: { CollectionsNavigation, CollectionsNotFound, FilterDrawerDetail },
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
@@ -128,7 +132,7 @@ export default defineComponent({
|
||||
const { selection } = useSelection();
|
||||
const { info: currentCollection, primaryKeyField } = useCollection(collection);
|
||||
const { addNewLink, batchLink, collectionsLink } = useLinks();
|
||||
const { viewOptions, viewQuery } = useCollectionPreset(collection);
|
||||
const { viewOptions, viewQuery, filters } = useCollectionPreset(collection);
|
||||
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
|
||||
|
||||
return {
|
||||
@@ -143,6 +147,7 @@ export default defineComponent({
|
||||
viewOptions,
|
||||
viewQuery,
|
||||
collectionsLink,
|
||||
filters,
|
||||
};
|
||||
|
||||
function useSelection() {
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #drawer><portal-target name="drawer" /></template>
|
||||
<template #drawer>
|
||||
<filter-drawer-detail v-model="filters" collection="directus_files" />
|
||||
<portal-target name="drawer" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-dialog v-model="confirmDelete">
|
||||
@@ -56,6 +59,7 @@
|
||||
:selection.sync="selection"
|
||||
:view-options.sync="viewOptions"
|
||||
:view-query.sync="viewQuery"
|
||||
:filters.sync="filters"
|
||||
:detail-route="'/{{project}}/files/{{primaryKey}}'"
|
||||
/>
|
||||
</private-view>
|
||||
@@ -69,6 +73,7 @@ import { i18n } from '@/lang';
|
||||
import api from '@/api';
|
||||
import { LayoutComponent } from '@/layouts/types';
|
||||
import useCollectionPreset from '@/compositions/use-collection-preset';
|
||||
import FilterDrawerDetail from '@/views/private/components/filter-drawer-detail';
|
||||
|
||||
type Item = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -77,7 +82,7 @@ type Item = {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'files-browse',
|
||||
components: { FilesNavigation },
|
||||
components: { FilesNavigation, FilterDrawerDetail },
|
||||
props: {},
|
||||
setup() {
|
||||
const layout = ref<LayoutComponent>(null);
|
||||
@@ -85,7 +90,7 @@ export default defineComponent({
|
||||
|
||||
const selection = ref<Item[]>([]);
|
||||
|
||||
const { viewOptions, viewQuery } = useCollectionPreset(ref('directus_files'));
|
||||
const { viewOptions, viewQuery, filters } = useCollectionPreset(ref('directus_files'));
|
||||
const { addNewLink, batchLink } = useLinks();
|
||||
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
|
||||
const { breadcrumb } = useBreadcrumb();
|
||||
@@ -101,6 +106,7 @@ export default defineComponent({
|
||||
layout,
|
||||
viewOptions,
|
||||
viewQuery,
|
||||
filters,
|
||||
};
|
||||
|
||||
function useBatchDelete() {
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #drawer><portal-target name="drawer" /></template>
|
||||
<template #drawer>
|
||||
<filter-drawer-detail v-model="filters" collection="directus_users" />
|
||||
<portal-target name="drawer" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-dialog v-model="confirmDelete">
|
||||
@@ -71,6 +74,7 @@ import { i18n } from '@/lang';
|
||||
import api from '@/api';
|
||||
import { LayoutComponent } from '@/layouts/types';
|
||||
import useCollectionPreset from '@/compositions/use-collection-preset';
|
||||
import FilterDrawerDetail from '@/views/private/components/filter-drawer-detail';
|
||||
|
||||
type Item = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -79,7 +83,7 @@ type Item = {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'users-browse',
|
||||
components: { UsersNavigation },
|
||||
components: { UsersNavigation, FilterDrawerDetail },
|
||||
props: {
|
||||
role: {
|
||||
type: String,
|
||||
@@ -136,6 +140,7 @@ export default defineComponent({
|
||||
viewOptions,
|
||||
viewQuery,
|
||||
_filters,
|
||||
filters,
|
||||
};
|
||||
|
||||
function useBatchDelete() {
|
||||
|
||||
@@ -21,6 +21,7 @@ export type FilterOperator =
|
||||
| 'has';
|
||||
|
||||
export type Filter = {
|
||||
key: string;
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: string;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { notEmpty } from '@/utils/is-empty/';
|
||||
import { i18n } from '@/lang';
|
||||
import formatTitle from '@directus/format-title';
|
||||
import notify from '@/utils/notify';
|
||||
import useRelationsStore from '@/stores/relations';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
|
||||
export const useFieldsStore = createStore({
|
||||
id: 'fieldsStore',
|
||||
@@ -251,5 +253,36 @@ export const useFieldsStore = createStore({
|
||||
|
||||
return primaryKeyField;
|
||||
},
|
||||
getFieldsForCollection(collection: string) {
|
||||
return this.state.fields.filter((field) => field.collection === collection);
|
||||
},
|
||||
getField(collection: string, fieldKey: string) {
|
||||
if (fieldKey.includes('.')) {
|
||||
return this.getRelationalField(collection, fieldKey);
|
||||
} else {
|
||||
return this.state.fields.find(
|
||||
(field) => field.collection === collection && field.field === fieldKey
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Retrieve field info for a (deeply) nested field
|
||||
* Recursively searches through the relationhips to find the field info that matches the
|
||||
* dot notation.
|
||||
*/
|
||||
getRelationalField(collection: string, fields: string) {
|
||||
const relationshipStore = useRelationsStore();
|
||||
const parts = fields.split('.');
|
||||
const relationshipForField = relationshipStore
|
||||
.getRelationsForField(collection, parts[0])
|
||||
?.find((relation: Relation) => relation.field_many === parts[0]);
|
||||
|
||||
if (relationshipForField === undefined) return false;
|
||||
|
||||
const relatedCollection = relationshipForField.collection_one;
|
||||
parts.shift();
|
||||
const relatedField = parts.join('.');
|
||||
return this.getField(relatedCollection, relatedField);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import VueI18n from 'vue-i18n';
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
type Translation = {
|
||||
locale: string;
|
||||
@@ -37,5 +37,5 @@ export interface FieldRaw {
|
||||
}
|
||||
|
||||
export interface Field extends FieldRaw {
|
||||
name: string | VueI18n.TranslateResult;
|
||||
name: string | TranslateResult;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createStore } from 'pinia';
|
||||
import { Relation } from './types';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import api from '@/api';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
|
||||
export const useRelationsStore = createStore({
|
||||
id: 'relationsStore',
|
||||
@@ -20,13 +21,35 @@ export const useRelationsStore = createStore({
|
||||
async dehydrate() {
|
||||
this.reset();
|
||||
},
|
||||
getRelationForField(collection: string, field: string) {
|
||||
getRelationsForCollection(collection: string) {
|
||||
return this.state.relations.filter((relation) => {
|
||||
return (
|
||||
(relation.collection_many === collection && relation.field_many === field) ||
|
||||
(relation.collection_one === collection && relation.field_one === field)
|
||||
relation.collection_many === collection ||
|
||||
relation.collection_one === collection
|
||||
);
|
||||
});
|
||||
},
|
||||
getRelationsForField(collection: string, field: string) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const fieldInfo = fieldsStore.getField(collection, field);
|
||||
|
||||
if (!fieldInfo) return [];
|
||||
|
||||
if (fieldInfo.type === 'file') {
|
||||
return [
|
||||
{
|
||||
collection_many: collection,
|
||||
field_many: field,
|
||||
collection_one: 'directus_files',
|
||||
field_one: null,
|
||||
junction_field: null,
|
||||
},
|
||||
] as Relation[];
|
||||
}
|
||||
|
||||
return this.getRelationsForCollection(collection).filter((relation: Relation) => {
|
||||
return relation.field_many === field || relation.field_one === field;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
3
src/styles/lib/_portal-vue.scss
Normal file
3
src/styles/lib/_portal-vue.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.vue-portal-target {
|
||||
display: contents;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
@import 'themes/dark';
|
||||
@import 'themes/light';
|
||||
@import 'lib/codemirror';
|
||||
@import 'lib/portal-vue';
|
||||
|
||||
body.light {
|
||||
@include light;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<div class="drawer-detail">
|
||||
<button class="toggle" @click="toggle" :class="{ open: active }">
|
||||
<div class="icon">
|
||||
<v-icon :name="icon" />
|
||||
<v-badge dot :disabled="!dot">
|
||||
<v-icon :name="icon" />
|
||||
</v-badge>
|
||||
</div>
|
||||
<div class="title" v-show="drawerOpen">
|
||||
{{ title }}
|
||||
@@ -13,9 +15,9 @@
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<v-divider />
|
||||
</div>
|
||||
</transition-expand>
|
||||
<v-divider v-if="active" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,6 +35,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
dot: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { active, toggle } = useGroupable(props.title, 'drawer-detail');
|
||||
@@ -93,6 +99,6 @@ export default defineComponent({
|
||||
.v-divider {
|
||||
--v-divider-color: var(--border-normal);
|
||||
|
||||
margin: 0 20px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="field-filter">
|
||||
<div class="header">
|
||||
<div class="name" v-tooltip="filter.field.split('.').join('\n')">
|
||||
<span v-if="filter.field.includes('.')" class="relational-indicator">•</span>
|
||||
{{ name }}
|
||||
</div>
|
||||
<v-menu show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<div class="operator" @click="toggle">
|
||||
<span>{{ $t(`operators.${activeOperator}`) }}</span>
|
||||
<v-icon name="expand_more" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item
|
||||
:active="operator === activeOperator"
|
||||
v-for="operator in parsedField.operators"
|
||||
:key="operator"
|
||||
@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 class="remove" name="close" @click="$emit('remove')" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<filter-input v-model="value" :type="parsedField.type" :operator="activeOperator" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import getAvailableOperatorsForType from './get-available-operators-for-type';
|
||||
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,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
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 parsedField = computed(() => {
|
||||
const field = getFieldForKey(props.filter.field);
|
||||
return getAvailableOperatorsForType(field.type);
|
||||
});
|
||||
|
||||
return { activeOperator, value, name, parsedField };
|
||||
|
||||
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;
|
||||
left: -12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&:hover {
|
||||
--v-icon-color: var(--danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<v-list-item
|
||||
v-if="field.children === undefined"
|
||||
@click="$emit('add', `${parent ? parent + '.' : ''}${field.field}`)"
|
||||
>
|
||||
<v-list-item-content>{{ field.name }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-group v-else>
|
||||
<template #activator>{{ field.name }}</template>
|
||||
<field-list-item
|
||||
v-for="childField in field.children"
|
||||
:key="childField.field"
|
||||
:parent="`${parent ? parent + '.' : ''}${field.field}`"
|
||||
:field="childField"
|
||||
@add="$emit('add', $event)"
|
||||
/>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from '@vue/composition-api';
|
||||
import { FieldTree } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'field-list-item',
|
||||
props: {
|
||||
field: {
|
||||
type: Object as PropType<FieldTree>,
|
||||
required: true,
|
||||
},
|
||||
parent: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<drawer-detail :dot="filters.length > 0" icon="filter_list" :title="$t('advanced_filter')">
|
||||
<field-filter
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
:filter="filter"
|
||||
:collection="collection"
|
||||
@update="updateFilter(filter.key, $event)"
|
||||
@remove="removeFilter(filter.key)"
|
||||
/>
|
||||
|
||||
<v-divider v-if="filters.length" />
|
||||
|
||||
<v-menu attached>
|
||||
<template #activator="{ toggle, active }">
|
||||
<v-input @click="toggle" :class="{ active }" readonly value="Add filter" full-width>
|
||||
<template #prepend><v-icon name="add" /></template>
|
||||
<template #append><v-icon name="expand_more" /></template>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<field-list-item
|
||||
@add="addFilterForField"
|
||||
v-for="field in fieldTree"
|
||||
:key="field.field"
|
||||
:field="field"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</drawer-detail>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed, ref, watch } from '@vue/composition-api';
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import useRelationsStore from '@/stores/relations';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import FieldFilter from './field-filter.vue';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { debounce } from 'lodash';
|
||||
import { FieldTree } from './types';
|
||||
import FieldListItem from './field-list-item.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: { FieldFilter, FieldListItem },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as PropType<Filter[]>,
|
||||
required: true,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const fieldTree = computed<FieldTree[]>(() => {
|
||||
return fieldsStore
|
||||
.getFieldsForCollection(props.collection)
|
||||
.filter(
|
||||
(field: Field) =>
|
||||
field.hidden_browse === false && field.type.toLowerCase() !== 'alias'
|
||||
)
|
||||
.map((field: Field) => parseField(field, []));
|
||||
|
||||
function parseField(field: Field, parents: Field[]) {
|
||||
const fieldInfo: FieldTree = {
|
||||
field: field.field,
|
||||
name: field.name,
|
||||
};
|
||||
|
||||
if (parents.length === 2) {
|
||||
return fieldInfo;
|
||||
}
|
||||
|
||||
const relations = relationsStore.getRelationsForField(
|
||||
field.collection,
|
||||
field.field
|
||||
);
|
||||
|
||||
if (relations.length > 0) {
|
||||
const relatedFields = relations
|
||||
.map((relation: Relation) => {
|
||||
const relatedCollection =
|
||||
relation.collection_many === field.collection
|
||||
? relation.collection_one
|
||||
: relation.collection_many;
|
||||
|
||||
if (relation.junction_field === field.field) return [];
|
||||
|
||||
return fieldsStore
|
||||
.getFieldsForCollection(relatedCollection)
|
||||
.filter(
|
||||
(field: Field) =>
|
||||
field.hidden_browse === false &&
|
||||
field.type.toLowerCase() !== 'alias'
|
||||
);
|
||||
})
|
||||
.flat()
|
||||
.map((childField: Field) => parseField(childField, [...parents, field]));
|
||||
|
||||
fieldInfo.children = relatedFields;
|
||||
}
|
||||
|
||||
return fieldInfo;
|
||||
}
|
||||
});
|
||||
|
||||
const localFilters = ref<Filter[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
localFilters.value = props.value?.filter((filter) => {
|
||||
return !!fieldsStore.getField(props.collection, filter.field);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const syncWithProp = debounce(() => {
|
||||
emit('input', localFilters.value);
|
||||
}, 500);
|
||||
|
||||
const filters = computed<Filter[]>({
|
||||
get() {
|
||||
return localFilters.value;
|
||||
},
|
||||
set(newFilters) {
|
||||
localFilters.value = newFilters;
|
||||
syncWithProp();
|
||||
},
|
||||
});
|
||||
|
||||
return { fieldTree, addFilterForField, filters, removeFilter, updateFilter };
|
||||
|
||||
function addFilterForField(fieldKey: string) {
|
||||
emit('input', [
|
||||
...props.value,
|
||||
{
|
||||
key: nanoid(),
|
||||
field: fieldKey,
|
||||
operator: 'eq',
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
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: 18px 0;
|
||||
}
|
||||
|
||||
.field-filter {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="filter-input">
|
||||
<template v-if="['between', 'nbetween'].includes(operator)">
|
||||
<v-input :type="type" :value="csvValue[0]" @input="setCSV(0, $event)" full-width>
|
||||
<template #append>
|
||||
<v-icon name="vertical_align_top" />
|
||||
</template>
|
||||
</v-input>
|
||||
<v-input :type="type" :value="csvValue[1]" @input="setCSV(1, $event)" full-width>
|
||||
<template #append>
|
||||
<v-icon name="vertical_align_bottom" />
|
||||
</template>
|
||||
</v-input>
|
||||
</template>
|
||||
<template v-else-if="['in', 'nin'].includes(operator)">
|
||||
<v-input
|
||||
v-for="(val, index) in csvValue"
|
||||
:key="index"
|
||||
:value="val"
|
||||
:type="type"
|
||||
@input="setCSV(index, $event)"
|
||||
full-width
|
||||
>
|
||||
<template #append>
|
||||
<v-icon v-if="csvValue.length > 1" name="close" @click="removeCSV(val)" />
|
||||
</template>
|
||||
</v-input>
|
||||
<v-button outlined dashed full-width @click="addCSV">
|
||||
<v-icon name="add" />
|
||||
{{ $t('add_new') }}
|
||||
</v-button>
|
||||
</template>
|
||||
<template v-else-if="['empty', 'nempty'].includes(operator) === false">
|
||||
<v-checkbox v-if="type === 'checkbox'" :inputValue="_value" />
|
||||
<v-input v-else v-model="_value" :type="type" full-width />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { FilterOperator } from '@/stores/collection-presets/types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'text' | 'checkbox' | 'number' | 'datetime' | 'unknown'>,
|
||||
required: true,
|
||||
},
|
||||
operator: {
|
||||
type: String as PropType<FilterOperator>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const _value = computed<string | string[]>({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(newValue) {
|
||||
emit('input', newValue);
|
||||
},
|
||||
});
|
||||
|
||||
const csvValue = computed({
|
||||
get() {
|
||||
return (props.value || '').split(',');
|
||||
},
|
||||
set(newVal: string[]) {
|
||||
_value.value = newVal.join(',');
|
||||
},
|
||||
});
|
||||
|
||||
return { _value, csvValue, setCSV, removeCSV, addCSV };
|
||||
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
export default function getAvailableOperatorsForType(type: string) {
|
||||
/**
|
||||
* @NOTE
|
||||
* In the filter, you can't filter on the relational field itself, so we don't have to account
|
||||
* for fields like m2o / file / etc
|
||||
*/
|
||||
|
||||
switch (type) {
|
||||
// Text
|
||||
case 'binary':
|
||||
case 'json':
|
||||
case 'array':
|
||||
case 'status':
|
||||
case 'slug':
|
||||
case 'lang':
|
||||
case 'uuid':
|
||||
case 'hash':
|
||||
case 'array':
|
||||
case 'string':
|
||||
return {
|
||||
type: 'text',
|
||||
operators: ['eq', 'neq', 'contains', 'ncontains', 'empty', 'nempty', 'in', 'nin'],
|
||||
};
|
||||
// Boolean
|
||||
case 'boolean':
|
||||
return {
|
||||
type: 'checkbox',
|
||||
operators: ['eq', 'empty', 'nempty'],
|
||||
};
|
||||
// Numbers
|
||||
case 'integer':
|
||||
case 'decimal':
|
||||
case 'sort':
|
||||
return {
|
||||
type: 'number',
|
||||
operators: [
|
||||
'eq',
|
||||
'neq',
|
||||
'lt',
|
||||
'lte',
|
||||
'gt',
|
||||
'gte',
|
||||
'between',
|
||||
'nbetween',
|
||||
'empty',
|
||||
'nempty',
|
||||
'in',
|
||||
'nin',
|
||||
],
|
||||
};
|
||||
// Datetime
|
||||
case 'datetime':
|
||||
case 'date':
|
||||
case 'time':
|
||||
case 'datetime_created':
|
||||
case 'datetime_updated':
|
||||
return {
|
||||
type: 'datetime',
|
||||
operators: [
|
||||
'eq',
|
||||
'neq',
|
||||
'lt',
|
||||
'lte',
|
||||
'gt',
|
||||
'gte',
|
||||
'between',
|
||||
'nbetween',
|
||||
'empty',
|
||||
'nempty',
|
||||
'in',
|
||||
'nin',
|
||||
],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
type: 'unknown',
|
||||
operators: [
|
||||
'eq',
|
||||
'neq',
|
||||
'lt',
|
||||
'lte',
|
||||
'gt',
|
||||
'gte',
|
||||
'contains',
|
||||
'ncontains',
|
||||
'between',
|
||||
'nbetween',
|
||||
'empty',
|
||||
'nempty',
|
||||
'in',
|
||||
'nin',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import FilterDrawerDetail from './filter-drawer-detail.vue';
|
||||
|
||||
export { FilterDrawerDetail };
|
||||
export default FilterDrawerDetail;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
export type FieldTree = {
|
||||
field: string;
|
||||
name: string | TranslateResult;
|
||||
children?: FieldTree[];
|
||||
};
|
||||
Reference in New Issue
Block a user