Skip to content

Commit

Permalink
Allow filtering calendar by keywords (indico#6183)
Browse files Browse the repository at this point in the history
Also add possibility to restrict which keywords can be used
  • Loading branch information
Moliholy authored Mar 5, 2024
1 parent 2cf335d commit 57980ee
Show file tree
Hide file tree
Showing 20 changed files with 282 additions and 70 deletions.
7 changes: 5 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ Improvements
thanks :user:`SegiNyn`)
- Use a more compact registration ticket QR code format which is faster to scan and less
likely to fail in poor lighting conditions (:pr:`6123`)
- Add a legend to the category calendar, allowing to filter events either by category, venue or room
(:issue:`6105, 6106, 6128, 6148, 6149`, :pr:`6110, 6158`, thanks :user:`Moliholy, unconventionaldotdev`)
- Add a legend to the category calendar, allowing to filter events either by category, venue,
room or keywords (:issue:`6105, 6106, 6128, 6148, 6149, 6127`, :pr:`6110, 6158, 6183`,
thanks :user:`Moliholy, unconventionaldotdev`)
- Allow to configure a restrictive set of allowed keywords (:issue:`6127`, :pr:`6183`,
thanks :user:`Moliholy, unconventionaldotdev`).
- Add week and day views in the category calendar and improve navigation controls
(:issue:`6108, 6129, 6107`, :pr:`6110`, thanks :user:`Moliholy, unconventionaldotdev`).
- Add the ability to clone privacy settings (:pr:`6156`, thanks :user:`SegiNyn`)
Expand Down
136 changes: 108 additions & 28 deletions indico/modules/categories/client/js/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import CalendarLegend from './components/CalendarLegend';
(function(global) {
let groupBy = 'category';
const filteredLegendElements = new Set();
const filteredKeywords = new Set();
const filteringByKeyword = () => groupBy === 'keywords';
let closeCalendar = null;
let ignoreClick = false;
global.setupCategoryCalendar = function setupCategoryCalendar(
Expand Down Expand Up @@ -105,11 +107,22 @@ import CalendarLegend from './components/CalendarLegend';
},
events({start, end}, successCallback, failureCallback) {
function updateCalendar(data) {
const attr = {category: 'categoryId', location: 'venueId', room: 'roomId'}[groupBy];
const attr = {
category: 'categoryId',
location: 'venueId',
room: 'roomId',
keywords: 'keywordId',
}[groupBy];
if (!attr) {
throw new Error(`Invalid "groupBy": ${groupBy}`);
}
const filteredEvents = data.events.filter(e => !filteredLegendElements.has(e[attr] ?? 0));
const filteredEvents = data.events.filter(e => {
let result = !filteredLegendElements.has(e[attr] ?? 0);
if (filteringByKeyword() && e.keywords.length) {
result ||= !e.keywords.every(kw => filteredKeywords.has(kw));
}
return result;
});
successCallback(filteredEvents);
const toolbarGroup = $(containerCalendarSelector).find(
'.fc-toolbar .fc-toolbar-chunk:last-child'
Expand Down Expand Up @@ -146,40 +159,46 @@ import CalendarLegend from './components/CalendarLegend';
const onFilterChanged = filterBy => {
groupBy = filterBy;
filteredLegendElements.clear();
filteredKeywords.clear();
calendar.refetchEvents();
};
const onElementSelected = (elementId, checked, elementSubitems, parent) => {
const handleSingle = (singleId, singleChecked) => {
const onElementSelected = (element, checked, refetch = true) => {
const handleSingle = (singleElement, singleChecked) => {
if (singleChecked) {
filteredLegendElements.delete(singleId);
filteredLegendElements.delete(singleElement.id);
} else {
filteredLegendElements.add(singleId);
filteredLegendElements.add(singleElement.id);
}
if (element.onClick) {
element.onClick(singleChecked, refetch);
}
};
const handleRecursive = (recursiveId, recursiveSubitems) => {
handleSingle(recursiveId, checked);
recursiveSubitems.forEach(({id, subitems}) => handleRecursive(id, subitems));
const handleRecursive = recursiveElement => {
handleSingle(recursiveElement, checked);
recursiveElement.subitems.forEach(elem => handleRecursive(elem));
};

// handle descendents
handleRecursive(elementId, elementSubitems);
handleRecursive(element);

// handle parent
if (parent) {
const parentChecked = !parent.subitems.every(({id}) =>
if (element.parent) {
const parentChecked = !element.parent.subitems.every(({id}) =>
filteredLegendElements.has(id)
);
handleSingle(parent.id, parentChecked);
handleSingle(element.parent, parentChecked);
}

calendar.refetchEvents();
if (refetch) {
calendar.refetchEvents();
}
};
const selectAll = () => {
items.forEach(({id, subitems}) => onElementSelected(id, true, subitems));
items.forEach(e => onElementSelected(e, true, false));
calendar.refetchEvents();
};
const deselectAll = () => {
items.forEach(({id, subitems}) => onElementSelected(id, false, subitems));
items.forEach(e => onElementSelected(e, false, false));
calendar.refetchEvents();
};
const filterLegendElement = item => {
Expand All @@ -195,18 +214,31 @@ import CalendarLegend from './components/CalendarLegend';
onElementSelected={onElementSelected}
selectAll={selectAll}
deselectAll={deselectAll}
filterByKeywords={data.allow_keywords}
/>,
legendContainer
);
}

function sortItems(items, sortMethod = undefined) {
items.sort((a, b) => {
if (a.isSpecial) {
return -1;
} else if (b.isSpecial) {
return 1;
}
return sortMethod ? sortMethod(a.title, b.title) : a.title.localeCompare(b.title);
});
return items;
}

function setupLegendByAttribute({
events,
items,
attr,
defaultTitle,
rootId,
rootTitle = undefined,
rootTitle,
sortMethod = undefined,
}) {
const itemMap = items.reduce(
Expand All @@ -217,8 +249,8 @@ import CalendarLegend from './components/CalendarLegend';
{}
);
const usedItems = new Set();
return events
.reduce((acc, value) => {
return sortItems(
events.reduce((acc, value) => {
const id = value[attr] ?? 0;
if (usedItems.has(id)) {
return acc;
Expand All @@ -238,15 +270,9 @@ import CalendarLegend from './components/CalendarLegend';
parent: null,
},
];
}, [])
.sort((a, b) => {
if (a.isSpecial) {
return -1;
} else if (b.isSpecial) {
return 1;
}
return sortMethod ? sortMethod(a.title, b.title) : a.title.localeCompare(b.title);
});
}, []),
sortMethod
);
}

function setupLegendByRoom(events, locations, rooms) {
Expand Down Expand Up @@ -306,6 +332,57 @@ import CalendarLegend from './components/CalendarLegend';
return [...noRoom, ...Object.values(groupedLocations)];
}

function setupLegendByKeywords(events, keywords) {
let items = [];
const noKeywords = events.filter(e => !e.keywords.length);
const manyKeywords = events.filter(e => e.keywords.length > 1);
if (noKeywords.length) {
items = [
...items,
...setupLegendByAttribute({
events: noKeywords,
items: keywords,
attr: 'keywordId',
defaultTitle: Translate.string('No keywords'),
rootId: 0,
}),
];
}
if (manyKeywords.length) {
items = [
...items,
...setupLegendByAttribute({
events: manyKeywords,
items: keywords,
attr: 'keywordId',
defaultTitle: Translate.string('Multiple keywords'),
rootId: 1,
}),
];
}
const extraItems = keywords.map(kw => ({
title: kw.title,
color: kw.color,
url: undefined,
isSpecial: false,
id: kw.id,
subitems: [],
parent: null,
onClick: (checked, refetch = true) => {
if (checked) {
filteredKeywords.delete(kw.title);
} else {
filteredKeywords.add(kw.title);
}

if (refetch) {
calendar.refetchEvents();
}
},
}));
return sortItems([...items, ...extraItems]);
}

function updateLegend(data) {
let items;
switch (data.group_by) {
Expand All @@ -331,6 +408,9 @@ import CalendarLegend from './components/CalendarLegend';
case 'room':
items = setupLegendByRoom(data.events, data.locations, data.rooms);
break;
case 'keywords':
items = setupLegendByKeywords(data.events, data.keywords);
break;
default:
items = [];
break;
Expand Down
35 changes: 20 additions & 15 deletions indico/modules/categories/client/js/components/CalendarLegend.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,31 +79,27 @@ LegendItem.defaultProps = {
hasSubitems: false,
};

function mapPlainItemToLegendItem(
{id, title, checked, url, color, isSpecial, subitems, parent},
onElementSelected,
depth
) {
const hasSubitems = subitems.length > 0;
function mapPlainItemToLegendItem(item, onElementSelected, depth) {
const hasSubitems = item.subitems.length > 0;
const indeterminate =
hasSubitems && subitems.some(si => si.checked) && subitems.some(si => !si.checked);
hasSubitems && item.subitems.some(si => si.checked) && item.subitems.some(si => !si.checked);
const result = (
<LegendItem
key={id}
title={title}
color={color}
checked={checked}
onChange={(_, data) => onElementSelected(id, data.checked, subitems, parent)}
url={url}
isSpecial={isSpecial}
key={item.id}
title={item.title}
color={item.color}
checked={item.checked}
onChange={(_, data) => onElementSelected(item, data.checked)}
url={item.url}
isSpecial={item.isSpecial}
depth={depth}
indeterminate={indeterminate}
hasSubitems={hasSubitems}
/>
);
return [
result,
...subitems.map(si => mapPlainItemToLegendItem(si, onElementSelected, depth + 1)),
...item.subitems.map(si => mapPlainItemToLegendItem(si, onElementSelected, depth + 1)),
];
}

Expand All @@ -114,6 +110,7 @@ function CalendarLegend({
onElementSelected,
selectAll,
deselectAll,
filterByKeywords,
}) {
const [isOpen, setIsOpen] = useState(false);
const parsedItems = items.map(item => mapPlainItemToLegendItem(item, onElementSelected, 0));
Expand All @@ -122,6 +119,9 @@ function CalendarLegend({
{text: Translate.string('Venue'), value: 'location'},
{text: Translate.string('Room'), value: 'room'},
];
if (filterByKeywords) {
options.push({text: Translate.string('Keywords'), value: 'keywords'});
}
const onChange = (_, {value}) => {
onFilterChanged(value);
setIsOpen(false);
Expand Down Expand Up @@ -158,6 +158,11 @@ CalendarLegend.propTypes = {
onElementSelected: PropTypes.func.isRequired,
selectAll: PropTypes.func.isRequired,
deselectAll: PropTypes.func.isRequired,
filterByKeywords: PropTypes.bool,
};

CalendarLegend.defaultProps = {
filterByKeywords: false,
};

export default CalendarLegend;
Loading

0 comments on commit 57980ee

Please sign in to comment.