* 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:
Rijk van Zanten
2020-04-15 21:15:44 -04:00
committed by GitHub
parent c6416ec3f9
commit e3985ad09b
25 changed files with 737 additions and 41 deletions

View File

@@ -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);

View File

@@ -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` |

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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),
},
});

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -21,6 +21,7 @@ export type FilterOperator =
| 'has';
export type Filter = {
key: string;
field: string;
operator: FilterOperator;
value: string;

View File

@@ -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);
},
},
});

View File

@@ -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;
}

View File

@@ -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;
});
},
},
});

View File

@@ -0,0 +1,3 @@
.vue-portal-target {
display: contents;
}

View File

@@ -6,6 +6,7 @@
@import 'themes/dark';
@import 'themes/light';
@import 'lib/codemirror';
@import 'lib/portal-vue';
body.light {
@include light;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
],
};
}
}

View File

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

View File

@@ -0,0 +1,7 @@
import { TranslateResult } from 'vue-i18n';
export type FieldTree = {
field: string;
name: string | TranslateResult;
children?: FieldTree[];
};