Skip to content

Commit cf96571

Browse files
authored
feat(ui): added tag filtering with AND/OR operators to the filters component
1 parent 474ee9a commit cf96571

5 files changed

Lines changed: 160 additions & 118 deletions

File tree

lib/public/app.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ html, body {
6565
color: #491217;
6666
}
6767

68+
.form-group-header {
69+
margin-bottom: 0.25rem;
70+
}
71+
6872
.nav {
6973
display: -ms-flexbox;
7074
display: flex;

lib/public/components/Filters/index.js

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,45 @@ import { iconMinus, iconPlus } from '/js/src/icons.js';
1616
const FILTERS_LIMIT = 5;
1717

1818
/**
19-
* Checkbox filter
19+
* Radio button to toggle the filter operation
20+
* @param {Object} model Pass the model to access the defined functions
21+
* @return {vnode} Return the form to be shown
22+
*/
23+
const filterOperationRadioButtons = (model) => h('.form-group-header.flex-row', ['AND', 'OR'].map((operation) =>
24+
h('.form-check', {
25+
style: 'margin-right: 0.5rem',
26+
}, [
27+
h('input.form-check-input', {
28+
onclick: () => model.logs.setFilterOperation(operation),
29+
id: `filterOperationRadioButton${operation}`,
30+
checked: operation === model.logs.getFilterOperation(),
31+
type: 'radio',
32+
name: 'operationRadioButtons',
33+
}),
34+
h('label.form-check-label', {
35+
for: `filterOperationRadioButton${operation}`,
36+
}, operation),
37+
])));
38+
39+
/**
40+
* Checkbox filter for a tag
2041
* @param {Object} model Pass the model to access the defined functions
2142
* @param {Array} tags Pass the tags to load in the view
2243
* @return {vnode} Return the form to be shown
2344
*/
24-
const checkboxFilter = (model, tags) => {
25-
const checkboxes = Object.entries(tags).map(([tag, count], index) => {
26-
const isChecked = model.logs.isTagInFilterCriteria(tag);
45+
const tagCheckboxes = (model, tags) => {
46+
const checkboxes = tags.map((tag, index) => {
47+
const isChecked = model.logs.isTagInFilter(tag.id);
2748
return h('.form-check', [
2849
h('input.form-check-input', {
29-
onclick: () => isChecked ? model.logs.removeFilter(tag) : model.logs.addFilter(tag),
30-
id: `filtersCheckbox${index + 1}`,
50+
onclick: () => isChecked ? model.logs.removeFilter(tag.id) : model.logs.addFilter(tag.id),
51+
id: `tagCheckbox${index + 1}`,
3152
type: 'checkbox',
3253
checked: isChecked,
3354
}),
3455
h('label.flex-row.items-center.form-check-label', {
35-
for: `filtersCheckbox${index + 1}`,
36-
}, tag, h('.f7.mh1.gray-darker', `(${count})`)),
56+
for: `tagCheckbox${index + 1}`,
57+
}, tag.text),
3758
]);
3859
});
3960

@@ -62,7 +83,8 @@ const filters = (model, tags) =>
6283
h('.w-25.h-100.shadow-level1.p2', [
6384
h('.f3', 'Filters'),
6485
h('.f4', 'Tags'),
65-
checkboxFilter(model, tags),
86+
filterOperationRadioButtons(model),
87+
tagCheckboxes(model, tags),
6688
]);
6789

6890
export default filters;

lib/public/views/Logs/Logs.js

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,33 @@ export default class Overview extends Observable {
4242
* @returns {undefined} Injects the data object with the response data
4343
*/
4444
async fetchAllLogs(offset = 0) {
45-
this.logs = RemoteData.loading();
46-
this.notify();
45+
if (!this.model.tags.getTags().isSuccess()) {
46+
this.logs = RemoteData.loading();
47+
this.notify();
48+
}
4749

48-
const endpoint = `/api/logs?page[offset]=${offset}&page[limit]=${this.logsPerPage}`;
50+
const params = {
51+
'page[offset]': offset,
52+
'page[limit]': this.logsPerPage,
53+
...this.filterTags.length > 0 && {
54+
'filter[tag][values]': this.filterTags.join(),
55+
'filter[tag][operation]': this.filterOperation.toLowerCase(),
56+
},
57+
};
58+
59+
const endpoint = `/api/logs?${new URLSearchParams(params).toString()}`;
4960
const response = await fetchClient(endpoint, { method: 'GET' });
5061
const result = await response.json();
5162

5263
if (result.data) {
5364
this.logs = RemoteData.success(result.data);
54-
this.filteredLogs = RemoteData.success(result.data);
5565
this.totalPages = result.meta.page.pageCount;
5666
} else {
5767
this.logs = RemoteData.failure(result.errors);
58-
this.filteredLogs = RemoteData.failure(result.errors);
68+
}
69+
70+
if (this.model.tags.getTags().isNotAsked()) {
71+
await this.model.tags.fetchAllTags();
5972
}
6073

6174
this.notify();
@@ -75,10 +88,8 @@ export default class Overview extends Observable {
7588

7689
if (result.data) {
7790
this.logs = RemoteData.success([result.data]);
78-
this.filteredLogs = RemoteData.success([result.data]);
7991
} else {
8092
this.logs = RemoteData.failure(result.errors);
81-
this.filteredLogs = RemoteData.failure(result.errors);
8293
}
8394
this.notify();
8495
}
@@ -164,7 +175,15 @@ export default class Overview extends Observable {
164175
* @returns {RemoteData} Returns all of the filtered logs
165176
*/
166177
getLogs() {
167-
return this.filteredLogs;
178+
return this.logs;
179+
}
180+
181+
/**
182+
* Getter for the filter operation
183+
* @returns {String} The filter operation to be performed (AND, OR)
184+
*/
185+
getFilterOperation() {
186+
return this.filterOperation;
168187
}
169188

170189
/**
@@ -221,8 +240,8 @@ export default class Overview extends Observable {
221240
* @returns {Array} Sets the data according to the filters applied
222241
*/
223242
addFilter(tag) {
224-
this.filterCriteria = [...this.filterCriteria, tag];
225-
this.setFilteredData();
243+
this.filterTags = [...this.filterTags, tag];
244+
this.fetchAllLogs();
226245
}
227246

228247
/**
@@ -231,28 +250,8 @@ export default class Overview extends Observable {
231250
* @returns {Array} Sets the array with the new filter criteria
232251
*/
233252
removeFilter(condition) {
234-
this.filterCriteria = this.filterCriteria.filter((tag) => tag !== condition);
235-
this.setFilteredData();
236-
}
237-
238-
/**
239-
* Filter the data
240-
* @returns {Array} Sets the data according to the amount of applied filters
241-
*/
242-
setFilteredData() {
243-
this.filterCriteria.length !== 0
244-
? this.filterByTags()
245-
: this.filteredLogs = RemoteData.success(this.logs.payload);
246-
247-
this.notify();
248-
}
249-
250-
/**
251-
* Filter data by tags if applicable
252-
* @returns {Array} Sets the filtered data on the criteria applied by the user
253-
*/
254-
filterByTags() {
255-
this.filteredLogs = RemoteData.success(this.logs.payload.filter((entry) => this.hasExistingTag(entry)));
253+
this.filterTags = this.filterTags.filter((tag) => tag !== condition);
254+
this.fetchAllLogs();
256255
}
257256

258257
/**
@@ -261,33 +260,28 @@ export default class Overview extends Observable {
261260
* @return {Boolean} Returns the status of the existence of a tag in the data entry
262261
*/
263262
hasExistingTag(entry) {
264-
return this.filterCriteria.filter((tag) => entry.tags.filter(({ text }) => tag === text).length > 0).length > 0;
263+
return this.filterTags.filter((tag) => entry.tags.filter(({ text }) => tag === text).length > 0).length > 0;
265264
}
266265

267266
/**
268-
* Counts the tags with their total appearances
269-
* @return {Object} Returns the count of each tag
267+
* Checks if a tag is already defined within the user's filter criteria
268+
* @param {String} tag The tag to check on
269+
* @return {Boolean} Whether the tag is in the user's filter criteria
270270
*/
271-
getTagCounts() {
272-
if (this.logs.isSuccess()) {
273-
return this.logs.payload.reduce((accumulator, currentValue) => {
274-
currentValue.tags.forEach(({ text: tag }) => {
275-
accumulator[tag] = (accumulator[tag] || 0) + 1;
276-
});
277-
return accumulator;
278-
}, {});
279-
} else {
280-
return {};
281-
}
271+
isTagInFilter(tag) {
272+
return this.filterTags.includes(tag);
282273
}
283274

284275
/**
285-
* Checks if a tag is already defined within the user's filter criteria
286-
* @param {String} tag The tag to check on
287-
* @return {Boolean} Whether the tag is in the user's filter criteria
276+
* Sets the filter operation according to the user input
277+
* @param {String} operation The filter operation to be performed (AND, OR)
278+
* @returns {undefined}
288279
*/
289-
isTagInFilterCriteria(tag) {
290-
return this.filterCriteria.includes(tag);
280+
setFilterOperation(operation) {
281+
this.filterOperation = operation;
282+
if (this.filterTags.length > 0) {
283+
this.fetchAllLogs();
284+
}
291285
}
292286

293287
/**
@@ -343,8 +337,8 @@ export default class Overview extends Observable {
343337
*/
344338
clearLogs() {
345339
this.logs = RemoteData.NotAsked();
346-
this.filteredLogs = RemoteData.NotAsked();
347-
this.filterCriteria = [];
340+
this.filterTags = [];
341+
this.filterOperation = 'AND';
348342
this.moreFilters = false;
349343

350344
this.amountDropdownVisible = false;

lib/public/views/Logs/Overview/index.js

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,41 +26,37 @@ const AVAILABLE_AMOUNTS = [5, 10, 20];
2626
*/
2727
const logOverviewScreen = (model) => {
2828
const data = model.logs.getLogs();
29+
const tags = model.tags.getTags();
2930

30-
if (!data.isLoading()) {
31-
const tags = model.logs.getTagCounts();
31+
const amountDropdownVisible = model.logs.isAmountDropdownVisible();
32+
const logsPerPage = model.logs.getLogsPerPage();
33+
const totalPages = model.logs.getTotalPages();
34+
const selectedPage = model.logs.getSelectedPage();
3235

33-
const amountDropdownVisible = model.logs.isAmountDropdownVisible();
34-
const logsPerPage = model.logs.getLogsPerPage();
35-
const totalPages = model.logs.getTotalPages();
36-
const selectedPage = model.logs.getSelectedPage();
37-
38-
return h('', [
39-
data.isFailure() && data.payload.map((error) =>
40-
h('.alert.alert-danger', h('b', `${error.title}: `), error.detail)),
41-
h('h2.mv2', { onremove: () => model.logs.clearLogs() }, 'Logs'),
42-
h('.flex-row', [
43-
filters(model, tags),
44-
h('.flex-column.mh3.w-100', [
45-
table(data.isSuccess() ? data.payload : [], ACTIVE_COLUMNS, (entry) => ({
46-
style: 'cursor: pointer;',
47-
onclick: () => model.router.go(`?page=entry&id=${entry.id}`),
48-
})),
49-
h('.flex-row.justify-between.mv3', [
50-
h('.w-15', amountSelector(() =>
51-
model.logs.toggleLogsDropdownVisible(), (amount) =>
52-
model.logs.setLogsPerPage(amount), amountDropdownVisible, AVAILABLE_AMOUNTS, logsPerPage)),
53-
pageSelector(totalPages, selectedPage, (page) => model.logs.setSelectedPage(page)),
54-
h('button.btn.btn-primary.w-15#create', {
55-
onclick: () => model.router.go('/?page=create-log-entry'),
56-
}, 'Add Entry'),
57-
]),
36+
return h('', { onremove: () => model.logs.clearLogs() }, [
37+
data.isLoading() && spinner(),
38+
data.isFailure() && data.payload.map((error) =>
39+
h('.alert.alert-danger', h('b', `${error.title}: `), error.detail)),
40+
h('h2.mv2', 'Logs'),
41+
h('.flex-row', [
42+
tags.isSuccess() && filters(model, tags.payload),
43+
data.isSuccess() && h('.flex-column.mh3.w-100', [
44+
table(data.isSuccess() ? data.payload : [], ACTIVE_COLUMNS, (entry) => ({
45+
style: 'cursor: pointer;',
46+
onclick: () => model.router.go(`?page=entry&id=${entry.id}`),
47+
})),
48+
h('.flex-row.justify-between.mv3', [
49+
h('.w-15', amountSelector(() =>
50+
model.logs.toggleLogsDropdownVisible(), (amount) =>
51+
model.logs.setLogsPerPage(amount), amountDropdownVisible, AVAILABLE_AMOUNTS, logsPerPage)),
52+
pageSelector(totalPages, selectedPage, (page) => model.logs.setSelectedPage(page)),
53+
h('button.btn.btn-primary.w-15#create', {
54+
onclick: () => model.router.go('/?page=create-log-entry'),
55+
}, 'Add Entry'),
5856
]),
5957
]),
60-
]);
61-
} else {
62-
return spinner();
63-
}
58+
]),
59+
]);
6460
};
6561

6662
export default (model) => [logOverviewScreen(model)];

0 commit comments

Comments
 (0)