UNPKG

@mitodl/course-search-utils

Version:

JS utils for interacting with MIT Open Course search

406 lines (405 loc) 20 kB
"use strict"; 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;