@mitodl/course-search-utils
Version:
JS utils for interacting with MIT Open Course search
406 lines (405 loc) • 20 kB
JavaScript
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (_) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.useCourseSearch = exports.useSyncUrlAndSearch = exports.useSearchInputs = exports.useFacetOptions = exports.mergeFacetResults = exports.buildSearchQuery = void 0;
var react_1 = require("react");
var ramda_1 = require("ramda");
var lodash_1 = __importDefault(require("lodash"));
var constants_1 = require("./constants");
var url_utils_1 = require("./url_utils");
var hooks_1 = require("./hooks");
__exportStar(require("./constants"), exports);
__exportStar(require("./url_utils"), exports);
var search_1 = require("./search");
Object.defineProperty(exports, "buildSearchQuery", { enumerable: true, get: function () { return search_1.buildSearchQuery; } });
var mergeFacetResults = function () {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
return ({
buckets: args
.map((0, ramda_1.prop)("buckets"))
// @ts-ignore
.reduce(function (buckets, acc) { return (0, ramda_1.unionWith)((0, ramda_1.eqBy)((0, ramda_1.prop)("key")), buckets, acc); })
});
};
exports.mergeFacetResults = mergeFacetResults;
/**
* Accounts for a difference in the listener API for v4 and v5.
* See https://github.com/remix-run/history/issues/811
*/
var history4or5Listen = function (history, listener) {
// @ts-ignore
return history.listen(function (e1, e2) {
if (e2) {
listener(e1, e2);
}
else {
listener(e1.location, e1.action);
}
});
};
var useFacetOptions = function (aggregations, activeFacets) {
return (0, react_1.useCallback)(function (group) {
var emptyFacet = { buckets: [] };
var emptyActiveFacets = {
buckets: (activeFacets[group] || []).map(function (facet) { return ({
key: facet,
doc_count: 0
}); })
};
if (!aggregations) {
return null;
}
return (0, exports.mergeFacetResults)(aggregations.get(group) || emptyFacet, emptyActiveFacets);
}, [aggregations, activeFacets]);
};
exports.useFacetOptions = useFacetOptions;
/**
* Provides state and event handlers for learning resources search UI; state
* includes data for facets, query text, sort order, ui variant (e.g.,
* 'compact').
*
* Note that there are two different state values for search text, `text` and
* `searchParams.text`. In the typical setup:
* - `text` represents the text currently displayed in the UI and updates
* frequently (e.g., on every keypress).
* - `searchParams.text` represents the text used for the currently displayed
* search results and updates less often (e.g., when a user presses "submit"
* or on debounced keypresses).
*
* The provided event handlers for updating other search parameters (sort, ui,
* facets) sync `text` -> `searchParams.text`.
*/
var useSearchInputs = function (history) {
var _a = (0, react_1.useState)(function () {
return (0, url_utils_1.deserializeSearchParams)(history.location);
}), searchParamsInternal = _a[0], setSearchParams = _a[1];
var searchParams = (0, react_1.useMemo)(function () {
return searchParamsInternal;
// This is intentional: let's maintain referential equality when
// serialization is the same.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [(0, url_utils_1.serializeSearchParams)(searchParamsInternal)]);
/**
* Store text in state + ref. State for re-renders, and ref for render-stable
* callbacks.
*/
var textRef = (0, react_1.useRef)(searchParamsInternal.text);
var _b = (0, react_1.useState)(searchParamsInternal.text), text = _b[0], setTextState = _b[1];
var setText = (0, react_1.useCallback)(function (val) {
setTextState(val);
textRef.current = val;
}, []);
var clearAllFilters = (0, react_1.useCallback)(function () {
setSearchParams({
text: "",
sort: null,
ui: null,
activeFacets: constants_1.INITIAL_FACET_STATE
});
setText("");
}, [setText]);
var toggleFacet = (0, react_1.useCallback)(function (name, value, isEnabled) {
setSearchParams(function (current) {
var activeFacets = current.activeFacets, sort = current.sort, ui = current.ui;
var newFacets = (0, ramda_1.clone)(activeFacets);
if (isEnabled) {
newFacets[name] = lodash_1.default.union(newFacets[name] || [], [value]);
}
else {
newFacets[name] = lodash_1.default.without(newFacets[name] || [], value);
}
return __assign(__assign({}, current), { activeFacets: newFacets, sort: sort, ui: ui, text: textRef.current });
});
}, []);
var toggleFacets = (0, react_1.useCallback)(function (facets) {
setSearchParams(function (current) {
var activeFacets = current.activeFacets, sort = current.sort, ui = current.ui;
var newFacets = (0, ramda_1.clone)(activeFacets);
facets.forEach(function (_a) {
var name = _a[0], value = _a[1], isEnabled = _a[2];
if (isEnabled) {
newFacets[name] = lodash_1.default.union(newFacets[name] || [], [value]);
}
else {
newFacets[name] = lodash_1.default.without(newFacets[name] || [], value);
}
});
return __assign(__assign({}, current), { activeFacets: newFacets, sort: sort, ui: ui, text: textRef.current });
});
}, []);
var onUpdateFacet = (0, react_1.useCallback)(function (e) { return toggleFacet(e.target.name, e.target.value, e.target.checked); }, [toggleFacet]);
var updateText = (0, react_1.useCallback)(function (event) {
var text = event ? event.target.value : "";
setText(text);
}, [setText]);
var updateSort = (0, react_1.useCallback)(function (event) {
var param = event ? event.target.value : "";
var newSort = (0, url_utils_1.deserializeSort)(param);
setSearchParams(function (current) { return (__assign(__assign({}, current), { sort: newSort, text: textRef.current })); });
}, []);
var updateUI = (0, react_1.useCallback)(function (newUI) {
setSearchParams(function (current) { return (__assign(__assign({}, current), { ui: newUI, text: textRef.current })); });
}, []);
var clearText = (0, react_1.useCallback)(function () {
setText("");
setSearchParams(function (current) { return (__assign(__assign({}, current), { text: "" })); });
}, [setText, setSearchParams]);
var submitText = (0, react_1.useCallback)(function () {
setSearchParams(function (current) { return (__assign(__assign({}, current), { text: textRef.current })); });
}, []);
return {
searchParams: searchParams,
setSearchParams: setSearchParams,
text: text,
setText: setText,
clearAllFilters: clearAllFilters,
toggleFacet: toggleFacet,
toggleFacets: toggleFacets,
onUpdateFacet: onUpdateFacet,
updateText: updateText,
updateSort: updateSort,
clearText: clearText,
updateUI: updateUI,
submitText: submitText
};
};
exports.useSearchInputs = useSearchInputs;
var setLocation = function (history, searchParams) {
var currentSearch = (0, url_utils_1.serializeSearchParams)((0, url_utils_1.deserializeSearchParams)(history.location));
var activeFacets = searchParams.activeFacets, sort = searchParams.sort, ui = searchParams.ui, text = searchParams.text;
var newSearch = (0, url_utils_1.serializeSearchParams)({
text: text,
activeFacets: activeFacets,
sort: sort,
ui: ui
});
if (currentSearch !== newSearch) {
var prefix = newSearch ? "?" : "";
history.push({
search: "".concat(prefix).concat(newSearch)
});
}
};
/**
* Sync changes to URL search parameters with `searchParams`, and vice versa.
*
* Pushes a new entry to the history stack every time the URL would change.
*/
var useSyncUrlAndSearch = function (history, _a) {
var searchParams = _a.searchParams, setSearchParams = _a.setSearchParams, setText = _a.setText;
// sync URL to search
(0, react_1.useEffect)(function () {
var unlisten = history4or5Listen(history, function (location) {
var _a = (0, url_utils_1.deserializeSearchParams)(location), activeFacets = _a.activeFacets, sort = _a.sort, ui = _a.ui, text = _a.text;
setSearchParams({ activeFacets: activeFacets, sort: sort, ui: ui, text: text });
setText(text);
});
return unlisten;
}, [history, setSearchParams, setText]);
(0, react_1.useEffect)(function () {
setLocation(history, searchParams);
}, [history, searchParams]);
};
exports.useSyncUrlAndSearch = useSyncUrlAndSearch;
var useCourseSearch = function (runSearch, clearSearch, aggregations, loaded, searchPageSize, history) {
var _a = (0, react_1.useState)(false), incremental = _a[0], setIncremental = _a[1];
var _b = (0, react_1.useState)(0), from = _b[0], setFrom = _b[1];
var seachUI = (0, exports.useSearchInputs)(history);
var searchParams = seachUI.searchParams, setSearchParams = seachUI.setSearchParams, text = seachUI.text, setText = seachUI.setText, clearAllFilters = seachUI.clearAllFilters, toggleFacet = seachUI.toggleFacet, toggleFacets = seachUI.toggleFacets, onUpdateFacets = seachUI.onUpdateFacet, updateText = seachUI.updateText, updateSort = seachUI.updateSort, updateUI = seachUI.updateUI;
var activeFacets = searchParams.activeFacets, sort = searchParams.sort, ui = searchParams.ui;
var activeFacetsAndSort = (0, react_1.useMemo)(function () { return ({ activeFacets: activeFacets, sort: sort, ui: ui }); }, [activeFacets, sort, ui]);
var facetOptions = (0, exports.useFacetOptions)(aggregations, activeFacets);
var internalRunSearch = (0, react_1.useCallback)(function (text, activeFacetsAndSort, incremental) {
if (incremental === void 0) { incremental = false; }
return __awaiter(void 0, void 0, void 0, function () {
var activeFacets, sort, ui, currentPageSize, nextFrom, searchFacets;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
activeFacets = activeFacetsAndSort.activeFacets, sort = activeFacetsAndSort.sort, ui = activeFacetsAndSort.ui;
currentPageSize = typeof searchPageSize === "number" ? searchPageSize : searchPageSize(ui);
nextFrom = from + currentPageSize;
if (!incremental) {
clearSearch();
nextFrom = 0;
}
setFrom(nextFrom);
setIncremental(incremental);
searchFacets = (0, ramda_1.clone)(activeFacets);
if (searchFacets.type !== undefined && searchFacets.type.length > 0) {
if (searchFacets.type.includes(constants_1.LearningResourceType.Podcast)) {
searchFacets.type.push(constants_1.LearningResourceType.PodcastEpisode);
}
if (searchFacets.type.includes(constants_1.LearningResourceType.Userlist)) {
searchFacets.type.push(constants_1.LearningResourceType.LearningPath);
}
}
else {
searchFacets.type = constants_1.LR_TYPE_ALL;
}
return [4 /*yield*/, runSearch(text, searchFacets, nextFrom, sort, ui)];
case 1:
_a.sent();
setLocation(history, { text: text, activeFacets: activeFacets, sort: sort, ui: ui });
return [2 /*return*/];
}
});
});
}, [
from,
setFrom,
setIncremental,
clearSearch,
runSearch,
searchPageSize,
history
]);
var initSearch = (0, react_1.useCallback)(function (location) {
var _a = (0, url_utils_1.deserializeSearchParams)(location), text = _a.text, activeFacets = _a.activeFacets, sort = _a.sort, ui = _a.ui;
clearSearch();
setText(text);
setSearchParams(function (current) { return (__assign(__assign({}, current), { activeFacets: activeFacets, sort: sort, ui: ui })); });
}, [clearSearch, setText, setSearchParams]);
var clearText = (0, react_1.useCallback)(function (event) {
event.preventDefault();
setText("");
internalRunSearch("", activeFacetsAndSort);
}, [activeFacetsAndSort, setText, internalRunSearch]);
var acceptSuggestion = (0, react_1.useCallback)(function (suggestion) {
setText(suggestion);
internalRunSearch(suggestion, activeFacetsAndSort);
}, [setText, activeFacetsAndSort, internalRunSearch]);
// this is our 'on startup' useEffect call
(0, react_1.useEffect)(function () {
initSearch(history.location);
// dependencies intentionally left blank here, because this effect
// needs to run only once - it's just to initialize the search state
// based on the value of the URL (if any)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
(0, react_1.useEffect)(function () {
// We push browser state into the history when a piece of the URL changes. However
// pressing the back button will update the browser stack but the UI does not respond by default.
// So we have to trigger this change explicitly.
var unlisten = history4or5Listen(history, function (location, action) {
if (action === "POP") {
// back button pressed
// @ts-ignore
initSearch(location);
}
});
return unlisten;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
var loadMore = (0, react_1.useCallback)(function () {
if (!loaded) {
// this function will be triggered repeatedly by <InfiniteScroll />, filter it to just once at a time
return;
}
internalRunSearch(text, activeFacetsAndSort, true);
}, [internalRunSearch, loaded, text, activeFacetsAndSort]);
// this effect here basically listens to parts of the search UI which should cause an immediate rerun of
// the search whenever they change. we always want these changes to take
// effect immediately, so we need to either do this or call runSearch from
// our facet-related callbacks. this approach lets us avoid having the
// facet-related callbacks (toggleFacet, etc) be dependent on then value of
// the runSearch function, which leads to too much needless churn in the
// facet callbacks and then causes excessive re-rendering of the facet UI
(0, hooks_1.useEffectAfterMount)(function () {
internalRunSearch(text, activeFacetsAndSort);
}, [activeFacetsAndSort]);
var onSubmit = (0, react_1.useCallback)(function (e) {
var _a;
if (e.type === "submit") {
(_a = e.preventDefault) === null || _a === void 0 ? void 0 : _a.call(e);
}
internalRunSearch(text, activeFacetsAndSort);
}, [internalRunSearch, text, activeFacetsAndSort]);
return {
facetOptions: facetOptions,
clearAllFilters: clearAllFilters,
toggleFacet: toggleFacet,
toggleFacets: toggleFacets,
onUpdateFacets: onUpdateFacets,
updateText: updateText,
clearText: clearText,
updateSort: updateSort,
acceptSuggestion: acceptSuggestion,
loadMore: loadMore,
incremental: incremental,
text: text,
sort: sort,
activeFacets: activeFacets,
onSubmit: onSubmit,
from: from,
updateUI: updateUI,
ui: ui
};
};
exports.useCourseSearch = useCourseSearch;
;