Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 117 additions & 30 deletions electrostoreFRONT/src/components/Filter.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div>
<div v-if="type !== 'hidden'">
<label v-if="label.length > 0" :for="`filter-input-${this.$.uid}`" class="text-sm text-gray-700 mr-2">{{ $t(label) }}</label>
<div>
<template v-if="type === 'select'">
Expand All @@ -10,7 +10,7 @@
@change="$emit('updateText', $event.target.value)">
<option value=""></option>
<template v-if="options">
<option v-for="[index, option] in options" :key="index" :value="index" :selected="preset === index">{{ option }}
<option v-for="option in filterOption" :key="option.id" :value="option.id" :selected="preset === option.id">{{ option.value }}
</option>
</template>
</select>
Expand All @@ -25,6 +25,7 @@
:class="[classCss, label.length > 0 ? 'mr-2' : '']"
:placeholder="placeholder"
v-model="inputText"
@input="storeData && storeKey ? debouncedRefetchData() : null"
@focus="isOpen = true; inputText='', startEventUpdatePosition()"
@blur="isOpen = false; validateInput(); endEventUpdatePosition()" />
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
Expand All @@ -46,11 +47,20 @@
<div v-show="isOpen"
:ref="`filterList`"
class="absolute border max-h-48 overflow-y-auto bg-white left-0" style="width: calc(100% - 8px);">
<div v-for="[index, option] in filterOption" :key="index"
@mousedown.prevent="selectOption(index, option)"
class="flex flex-col p-2 hover:bg-gray-100 cursor-pointer">
<span class="text-sm">{{ option }}</span>
</div>
<template v-if="options && !storeData">
<div v-for="option in filterOption" :key="option.id"
@mousedown.prevent="selectOption(option.id, option.value)"
class="flex flex-col p-2 hover:bg-gray-100 cursor-pointer">
<span class="text-sm">{{ option.value }}</span>
</div>
</template>
<template v-else>
<div v-for="[index, option] in filterStoreOption" :key="index"
@mousedown.prevent="selectOption(index, option)"
class="flex flex-col p-2 hover:bg-gray-100 cursor-pointer">
<span class="text-sm">{{ option }}</span>
</div>
</template>
</div>
</teleport>
</template>
Expand Down Expand Up @@ -79,6 +89,9 @@

<script>
import { nextTick } from "vue";
import { debounce } from "lodash-es";
import { buildRSQLFilter, buildRSQLSort } from "@/utils";
import { toLowerCaseWithoutAccents } from "@/utils";
export default {
name: "Filter",
props: {
Expand All @@ -93,7 +106,7 @@ export default {
type: String,
required: true,
// This should be a valid input type
// 'text', 'number', 'select', etc.
// 'text', 'number', 'select', 'datalist', etc.
default: "text",
},
placeholder: {
Expand All @@ -116,13 +129,40 @@ export default {
default: "",
},
options: {
type: Object,
type: Array,
required: false,
// This should be an array of options for select input
// e.g., {[id]: 'Option 1', [id2]: 'Option 2'}
// translate the labels before passing
// This should be an array of options for select/datalist input
// e.g., [{id: 'id1', value: 'Option 1'}, {id: 'id2', value: 'Option 2'}]
// translate the values before passing
// to the component
default: () => ({}),
default: () => ([]),
},
sortOptions: {
type: String,
required: false,
// This should be a string indicating how to sort the options, e.g., 'asc' or 'desc'
default: null,
},
fetchOptions: {
type: Function,
required: false,
// This should be a function that returns a promise resolving to an array of options
// e.g., () => fetch('/api/options').then(res => res.json())
default: (limit, offset, expand, filter, sort, clear) => {
return [0, false];
},
},
storeData: {
type: Object,
required: false,
// This should be a pinia store whose data will be received by the fetchOptions function
default: null,
},
storeKey: {
type: String,
required: false,
// This should be the key in storeData to pass to fetchOptions function
default: null,
},
},
data() {
Expand All @@ -131,18 +171,55 @@ export default {
inputText: this.preset,
};
},
created() {
this.debouncedRefetchData = debounce(this.refetchData, 500);
},
mounted() {
if (this.type === "datalist" && this.preset) {
const found = this.options && this.options.find((o) => String(o.id) === String(this.preset));
if (found) {
this.inputText = found.value;
} else if (this.storeData && this.storeKey) {
this.inputText = this.preset;
}
}
if (this.type === "datalist" && this.fetchOptions && this.storeData && this.storeKey) {
this.refetchData();
}
},
emits: ["updateText"],
computed: {
filterOption() {
if (!this.options) {
return [];
}
return Object.entries(this.options).filter(([index, element]) => {
let result = this.options.filter((option) => {
if (this.inputText !== "") {
return element.toLowerCase().includes(this.inputText.toLowerCase());
return toLowerCaseWithoutAccents(String(option.value)).includes(toLowerCaseWithoutAccents(this.inputText));
}
return true;
});
if (this.sortOptions) {
result = result.slice().sort((a, b) => {
const aVal = toLowerCaseWithoutAccents(String(a.value));
const bVal = toLowerCaseWithoutAccents(String(b.value));
return this.sortOptions === "desc" ? bVal.localeCompare(aVal) : aVal.localeCompare(bVal);
});
}
return result;
},
filterStoreOption() {
if (!this.storeData || !this.storeKey) {
return [];
}
return Object.entries(this.storeData).filter(([index, element]) => {
if (this.inputText !== "") {
return toLowerCaseWithoutAccents(element[this.storeKey]).includes(toLowerCaseWithoutAccents(this.inputText));
}
return true;
}).map(([index, element]) => {
return [element[this.storeKey], element[this.storeKey]];
});
},
},
methods: {
Expand All @@ -153,20 +230,19 @@ export default {
this.$refs.filterInput.blur();
},
validateInput(){
if (!this.options) {
this.inputText = "";
this.$emit("updateText", "");
return;
}
let result = Object.entries(this.options).find(([index, option]) => {
return option.toLowerCase() === this.inputText.toLowerCase();
});
if (result) {
this.inputText = result[1];
this.$emit("updateText", result[0]);
} else {
this.inputText = "";
this.$emit("updateText", "");
if (this.options && this.options.length > 0 && !this.storeData) {
const result = this.options.find((option) => {
return toLowerCaseWithoutAccents(String(option.value)) === toLowerCaseWithoutAccents(this.inputText);
});
if (result) {
this.inputText = result.value;
this.$emit("updateText", result.id);
} else {
this.inputText = "";
this.$emit("updateText", "");
}
} else if (this.storeData && this.storeKey) {
this.$emit("updateText", this.inputText);
}
},
startEventUpdatePosition(){
Expand All @@ -193,7 +269,18 @@ export default {
listElement.style.width = `${rect.width}px`;
}
},

async refetchData() {
const filter = [{
key: this.storeKey,
compareMethod: "=like=",
value: this.inputText,
}];
const sort = {
key: this.storeKey,
order: "asc",
};
await this.fetchOptions(10, 0, [], buildRSQLFilter(filter), buildRSQLSort(sort), false);
},
},
};
</script>
52 changes: 46 additions & 6 deletions electrostoreFRONT/src/components/FilterContainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
:preset="filter?.value"
:placeholder="filter?.placeholder"
:class-css="filter?.class"
:options="filter?.options"
:options="filter?.options ? Object.keys(filter.options).map((key) => ({ id: key, value: filter.options[key] })) : null"
:sort-options="filter?.sortOptions"
:fetch-options="filter?.fetchOptions"
:store-data="filter?.storeData"
:store-key="filter?.storeKey"
Expand All @@ -28,22 +29,58 @@ export default {
// - key: string (the key in the storeData to filter on)
// - label: string (translation key for the label)
// - type: string (input type, e.g., 'text', 'number', 'select')
// - typeData: string (type of data, e.g., 'int', 'float', 'string', 'bool') required if type is 'select'
// - compareMethod: string (comparison method, e.g., '==', '=ge=', '=le=', '=like=')
// - value: any (the value to filter by, can be empty)
// - placeholder: string (translation key for the placeholder, optional)
// - class: string (tailwind CSS class for styling, optional)
// - options: array (for select inputs, optional, required if type is 'select')
//todo// - fetchOptions: function (optional, required if type is 'select' and options is not provided) that returns a promise resolving to an array of options
//todo// - storeData: pinia store (optional, required if fetchOptions is provided) the store whose data will be received by the fetchOptions function
//todo// - storeKey: string (optional, required if fetchOptions is provided) the key in storeData to pass to fetchOptions function
// - fetchOptions: function (optional, required if type is 'select' or 'datalist' and options is not provided) that returns a promise resolving to an array of options
// - storeData: pinia store (optional, required if fetchOptions is provided) the store whose data will be received by the fetchOptions function
// - storeKey: string (optional, required if fetchOptions is provided) the key in storeData to pass to fetchOptions function
default: () => [],
},
saveState: {
type: Boolean,
default: false,
},
stateKey: {
type: String,
default: null,
// optional unique key to identify this filter set in sessionStorage
},
},
emits: ["ready"],
components: {
Filter: defineAsyncComponent(() => import("@/components/Filter.vue")),
},
beforeMount() {
if (this.saveState) {
const saved = sessionStorage.getItem(this._filterStateKey());
if (saved) {
const savedValues = JSON.parse(saved);
for (const filter of this.filters) {
if (filter.key in savedValues) {
filter.value = savedValues[filter.key];
filter.preset = savedValues[filter.key];
}
}
}
}
this.$emit("ready");
},
methods: {
_filterStateKey() {
return `filter_state_${this.$route?.path || ""}_${this.stateKey || "default"}`;
},
_persistFilters() {
const values = {};
for (const filter of this.filters) {
if (filter.key !== undefined) {
values[filter.key] = filter.value;
}
}
sessionStorage.setItem(this._filterStateKey(), JSON.stringify(values));
},
updateText(key, value) {
for (const [index, filter] of this.filters.entries()) {
if (index === key) {
Expand All @@ -59,7 +96,7 @@ export default {
break;
case "select":
switch (filter.typeData) {
case "int":
case "number":
value = Number.parseInt(value);
if (Number.isNaN(value)) {
value = "";
Expand All @@ -83,6 +120,9 @@ export default {
filter.value = value;
}
}
if (this.saveState) {
this._persistFilters();
}
},
},
};
Expand Down
5 changes: 4 additions & 1 deletion electrostoreFRONT/src/views/InventoryView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const filter = ref([
{ key: "friendly_name_item", value: "", type: "text", label: "items.FilterFriendlyName", compareMethod: "=like=" },
{ key: "seuil_min_item", value: "", type: "number", label: "items.FilterSeuilMin", compareMethod: "=ge=" },
{ key: "seuil_min_item", value: "", type: "number", label: "items.FilterSeuilMax", compareMethod: "=le=" },
{ key: "ItemsTags.Tag.nom_tag", value: "", type: "text", label: "items.FilterTag", compareMethod: "=like=" },
{ key: "ItemsTags.Tag.nom_tag", value: "", type: "datalist", label: "items.FilterTag", compareMethod: "=like=",
fetchOptions: (limit, offset, expand, filter, sort, clear) => tagsStore.getTagByInterval(limit, offset, expand, filter, sort, clear),
storeData: tagsStore.tags, storeKey: "nom_tag",
},
]);
const tableauLabel = ref([
{ label: "items.Name", sortable: true, key: "reference_name_item", valueKey: "reference_name_item", type: "text" },
Expand Down
2 changes: 1 addition & 1 deletion electrostoreFRONT/src/views/ProjetsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const projetTypeStatus = ref({ [ProjetStatus.NotStarted]: t("projets.Status0"),
[ProjetStatus.Cancelled]: t("projets.Status4"), [ProjetStatus.Archived]: t("projets.Status5") });

const filter = ref([
{ key: "status_projet", value: "", type: "datalist", typeData: "int", options: projetTypeStatus, label: "projets.FilterStatus", compareMethod: "==" },
{ key: "status_projet", value: "", type: "datalist", typeData: "number", options: projetTypeStatus, label: "projets.FilterStatus", compareMethod: "==" },
{ key: "nom_projet", value: "", type: "text", label: "projets.FilterNom", compareMethod: "=like=" },
{ key: "url_projet", value: "", type: "text", label: "projets.FilterUrl", compareMethod: "=like=" },
{ key: "date_debut_projet", value: "", type: "date", label: "projets.FilterDate", compareMethod: "=ge=" },
Expand Down
2 changes: 1 addition & 1 deletion electrostoreFRONT/src/views/UserView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ if (!authStore.isSSOUser && (authStore.user?.id_user === Number(userId.value) ||
}

const filterSession = ref([
{ key: "is_revoked", replaceKeyApi: "is_revoked", value: "", typeData: "bool", valueIfTrue: "true", valueIfFalse: "", preset: false,
{ key: "is_revoked", disableLocalFilter: true, value: "", typeData: "bool", valueIfTrue: "true", valueIfFalse: "", preset: false,
type: "checkbox", label: "user.ShowExpiredAndRevokedTokens", compareMethod: "==" },
]);
const labelTableauSession = ref([
Expand Down
2 changes: 1 addition & 1 deletion electrostoreFRONT/src/views/UsersView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const filter = ref([
{ key: "nom_user", value: "", type: "text", label: "users.FilterName", compareMethod: "=like=" },
{ key: "prenom_user", value: "", type: "text", label: "users.FilterFirstName", compareMethod: "=like=" },
{ key: "email_user", value: "", type: "text", label: "users.FilterEmail", compareMethod: "=like=" },
{ key: "role_user", value: "", type: "datalist", typeData: "int", options: userTypeRole, label: "users.FilterRole", compareMethod: "==" },
{ key: "role_user", value: "", type: "datalist", typeData: "number", options: userTypeRole, sortOptions: "asc", label: "users.FilterRole", compareMethod: "==" },
]);
const tableauLabel = ref([
{ label: "users.Name", sortable: true, key: "nom_user", valueKey: "nom_user", type: "text" },
Expand Down
Loading