From 17fabbbfc67166b74692a2c775e15d8f24d31714 Mon Sep 17 00:00:00 2001
From: Kout95 <65901733+Kout95@users.noreply.github.com>
Date: Tue, 18 Jun 2024 12:11:58 +0200
Subject: [PATCH] feat: add search params in url (#142)
---
frontend/public/off.html | 2 +-
frontend/src/mixins/history.ts | 189 +++++++++++++++++++++++++++
frontend/src/mixins/search-ctl.ts | 173 +++++++++++++++++-------
frontend/src/search-facets.ts | 34 ++++-
frontend/src/test/search-bar_test.ts | 2 +-
frontend/src/utils/constants.ts | 2 +
frontend/src/utils/enums.ts | 9 ++
frontend/src/utils/index.ts | 26 ++++
frontend/src/utils/url.ts | 93 +++++++++++++
9 files changed, 481 insertions(+), 49 deletions(-)
create mode 100644 frontend/src/mixins/history.ts
create mode 100644 frontend/src/utils/index.ts
create mode 100644 frontend/src/utils/url.ts
diff --git a/frontend/public/off.html b/frontend/public/off.html
index 3c697db8..efd673d7 100644
--- a/frontend/public/off.html
+++ b/frontend/public/off.html
@@ -153,7 +153,7 @@
-
+
diff --git a/frontend/src/mixins/history.ts b/frontend/src/mixins/history.ts
new file mode 100644
index 00000000..bed42cf8
--- /dev/null
+++ b/frontend/src/mixins/history.ts
@@ -0,0 +1,189 @@
+import {LitElement} from 'lit';
+import {
+ addParamPrefixes,
+ removeParamPrefixes,
+ removeParenthesis,
+} from '../utils/url';
+import {isNullOrUndefined} from '../utils';
+import {BuildParamsOutput} from './search-ctl';
+import {property} from 'lit/decorators.js';
+import {QueryOperator} from '../utils/enums';
+import {SearchaliciousFacets} from '../search-facets';
+import {Constructor} from './utils';
+
+export type SearchaliciousHistoryInterface = {
+ query: string;
+ name: string;
+ _currentPage?: number;
+ _facetsNodes: () => SearchaliciousFacets[];
+ _facetsFilters: () => string;
+ convertHistoryParamsToValues: (params: URLSearchParams) => HistoryOutput;
+ setValuesFromHistory: (values: HistoryOutput) => void;
+ buildHistoryParams: (params: BuildParamsOutput) => HistoryParams;
+ setParamFromUrl: () => {launchSearch: boolean; values: HistoryOutput};
+};
+
+export type HistoryOutput = {
+ query?: string;
+ page?: number;
+ selectedTermsByFacet?: Record;
+};
+/**
+ * Parameters we need to put in URL to be able to deep link the search
+ */
+export enum HistorySearchParams {
+ QUERY = 'q',
+ FACETS_FILTERS = 'facetsFilters',
+ PAGE = 'page',
+}
+
+// name of search params as an array (to ease iteration)
+export const SEARCH_PARAMS = Object.values(HistorySearchParams);
+
+// type of object containing search parameters
+export type HistoryParams = {
+ [key in HistorySearchParams]?: string;
+};
+/**
+ * Object to convert the URL params to the original values
+ *
+ * It maps parameter names to a function to transforms it to a JS value
+ */
+const HISTORY_VALUES: Record<
+ HistorySearchParams,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (history: Record) => Record
+> = {
+ [HistorySearchParams.PAGE]: (history) =>
+ history.page
+ ? {
+ page: parseInt(history.page),
+ }
+ : {},
+ [HistorySearchParams.QUERY]: (history) => {
+ // Avoid empty query but allow empty string
+ if (isNullOrUndefined(history.q)) {
+ return {};
+ }
+ return {
+ query: history.q,
+ };
+ },
+ [HistorySearchParams.FACETS_FILTERS]: (history) => {
+ if (!history.facetsFilters) {
+ return {};
+ }
+ // we split back the facetsFilters expression to its sub components
+ // parameter value is facet1:(value1 OR value2) AND facet2:(value3 OR value4)
+ const selectedTermsByFacet = history.facetsFilters
+ .split(QueryOperator.AND)
+ .reduce((acc, filter) => {
+ const [key, value] = filter.split(':');
+ acc[key] = removeParenthesis(value).split(QueryOperator.OR);
+ return acc;
+ }, {} as Record);
+
+ return {
+ selectedTermsByFacet,
+ };
+ },
+};
+/**
+ * Mixin to handle the history of the search
+ * It exists to have the logic of the history in a single place
+ * It has to be inherited by SearchaliciousSearchMixin to implement _facetsNodes and _facetsFilters functionss
+ * @param superClass
+ * @constructor
+ */
+export const SearchaliciousHistoryMixin = >(
+ superClass: T
+) => {
+ class SearchaliciousHistoryMixinClass extends superClass {
+ /**
+ * Query that will be sent to searchalicious
+ */
+ @property({attribute: false})
+ query = '';
+
+ /**
+ * The name of this search
+ */
+ @property()
+ name = 'searchalicious';
+
+ _facetsNodes = (): SearchaliciousFacets[] => {
+ throw new Error('Method not implemented.');
+ };
+
+ _facetsFilters = (): string => {
+ throw new Error('Method not implemented.');
+ };
+
+ /**
+ * Convert the URL params to the original values
+ * It will be used to set the values from the URL
+ * @param params
+ */
+ convertHistoryParamsToValues = (params: URLSearchParams): HistoryOutput => {
+ const values: HistoryOutput = {};
+ const history = removeParamPrefixes(
+ Object.fromEntries(params),
+ this.name
+ );
+ for (const key of SEARCH_PARAMS) {
+ Object.assign(values, HISTORY_VALUES[key](history));
+ }
+ return values;
+ };
+
+ /**
+ * Set the values from the history
+ * It will set the params from the URL params
+ * @param values
+ */
+ setValuesFromHistory = (values: HistoryOutput) => {
+ this.query = values.query ?? '';
+ if (values.selectedTermsByFacet) {
+ this._facetsNodes().forEach((facets) =>
+ facets.setSelectedTermsByFacet(values.selectedTermsByFacet!)
+ );
+ }
+ };
+
+ /**
+ * Build the history params from the current state
+ * It will be used to update the URL when searching
+ * @param params
+ */
+ buildHistoryParams = (params: BuildParamsOutput) => {
+ return addParamPrefixes(
+ {
+ [HistorySearchParams.QUERY]: this.query,
+ [HistorySearchParams.FACETS_FILTERS]: this._facetsFilters(),
+ [HistorySearchParams.PAGE]: params.page,
+ },
+ this.name
+ ) as HistoryParams;
+ };
+
+ /**
+ * Set the values from the URL
+ * It will set the values from the URL and return if a search should be launched
+ */
+ setParamFromUrl = () => {
+ const params = new URLSearchParams(window.location.search);
+ const values = this.convertHistoryParamsToValues(params);
+
+ this.setValuesFromHistory(values);
+
+ const launchSearch = !!Object.keys(values).length;
+ return {
+ launchSearch,
+ values,
+ };
+ };
+ }
+
+ return SearchaliciousHistoryMixinClass as Constructor &
+ T;
+};
diff --git a/frontend/src/mixins/search-ctl.ts b/frontend/src/mixins/search-ctl.ts
index 6a87d1bc..3f04a82e 100644
--- a/frontend/src/mixins/search-ctl.ts
+++ b/frontend/src/mixins/search-ctl.ts
@@ -4,17 +4,34 @@ import {
EventRegistrationInterface,
EventRegistrationMixin,
} from '../event-listener-setup';
-import {SearchaliciousEvents} from '../utils/enums';
+import {QueryOperator, SearchaliciousEvents} from '../utils/enums';
import {
ChangePageEvent,
LaunchSearchEvent,
SearchResultEvent,
SearchResultDetail,
} from '../events';
-import {SearchaliciousFacets} from '../search-facets';
import {Constructor} from './utils';
+import {SearchaliciousFacets} from '../search-facets';
+import {setCurrentURLHistory} from '../utils/url';
+import {FACETS_DIVIDER} from '../utils/constants';
+import {
+ HistorySearchParams,
+ SearchaliciousHistoryInterface,
+ SearchaliciousHistoryMixin,
+} from './history';
+
+export type BuildParamsOutput = {
+ q: string;
+ langs: string;
+ page_size: string;
+ page?: string;
+ index?: string;
+ facets?: string;
+};
export interface SearchaliciousSearchInterface
- extends EventRegistrationInterface {
+ extends EventRegistrationInterface,
+ SearchaliciousHistoryInterface {
query: string;
name: string;
baseUrl: string;
@@ -23,28 +40,32 @@ export interface SearchaliciousSearchInterface
pageSize: number;
search(): Promise;
+ _facetsNodes(): SearchaliciousFacets[];
+ _facetsFilters(): string;
}
+// name of search params as an array (to ease iteration)
+
export const SearchaliciousSearchMixin = >(
superClass: T
) => {
/**
* The search mixin, encapsulate the logic of dialog with server
*/
- class SearchaliciousSearchMixinClass extends EventRegistrationMixin(
- superClass
+ class SearchaliciousSearchMixinClass extends SearchaliciousHistoryMixin(
+ EventRegistrationMixin(superClass)
) {
/**
* Query that will be sent to searchalicious
*/
@property({attribute: false})
- query = '';
+ override query = '';
/**
* The name of this search
*/
@property()
- name = 'searchalicious';
+ override name = 'searchalicious';
/**
* The base api url
@@ -74,7 +95,7 @@ export const SearchaliciousSearchMixin = >(
* Number of result per page
*/
@state()
- _currentPage?: number;
+ override _currentPage?: number;
/**
* Last search page count
@@ -94,10 +115,38 @@ export const SearchaliciousSearchMixin = >(
@state()
_count?: number;
+ /**
+ * Wether search should be launched at page load
+ */
+ @property({attribute: 'auto-launch', type: Boolean})
+ autoLaunch = false;
+
+ /**
+ * Launch search at page loaded if needed (we have a search in url)
+ */
+ firstSearch = () => {
+ // we need to wait for the facets to be ready
+ setTimeout(() => {
+ const {launchSearch, values} = this.setParamFromUrl();
+ if (this.autoLaunch || launchSearch) {
+ // launch the first search event to trigger the search only once
+ this.dispatchEvent(
+ new CustomEvent(SearchaliciousEvents.LAUNCH_FIRST_SEARCH, {
+ bubbles: true,
+ composed: true,
+ detail: {
+ page: values[HistorySearchParams.PAGE],
+ },
+ })
+ );
+ }
+ }, 0);
+ };
+
/**
* @returns all searchalicious-facets elements linked to this search ctl
*/
- _facetsNodes(): SearchaliciousFacets[] {
+ override _facetsNodes = (): SearchaliciousFacets[] => {
const allNodes: SearchaliciousFacets[] = [];
// search facets elements, we can't filter on search-name because of default value…
const facetsElements = document.querySelectorAll('searchalicious-facets');
@@ -108,7 +157,7 @@ export const SearchaliciousSearchMixin = >(
}
});
return allNodes;
- }
+ };
/**
* Get the list of facets we want to request
@@ -124,45 +173,21 @@ export const SearchaliciousSearchMixin = >(
* Get the filter linked to facets
* @returns an expression to be added to query
*/
- _facetsFilters(): string {
+ override _facetsFilters = (): string => {
const allFilters: string[] = this._facetsNodes()
.map((facets) => facets.getSearchFilters())
.flat();
- return allFilters.join(' AND ');
- }
-
+ return allFilters.join(QueryOperator.AND);
+ };
+ /*
+ * Compute search URL, associated parameters and history entry
+ * based upon the requested page, and the state of other search components
+ * (search bar, facets, etc.)
+ */
_searchUrl(page?: number) {
// remove trailing slash
const baseUrl = this.baseUrl.replace(/\/+$/, '');
- const queryParts = [];
- if (this.query) {
- queryParts.push(this.query);
- }
- const facetsFilters = this._facetsFilters();
- if (facetsFilters) {
- queryParts.push(facetsFilters);
- }
- // build parameters
- const params: {
- q: string;
- langs: string;
- page_size: string;
- page?: string;
- index?: string;
- facets?: string;
- } = {
- q: queryParts.join(' '),
- langs: this.langs,
- page_size: this.pageSize.toString(),
- index: this.index,
- };
- if (page) {
- params.page = page.toString();
- }
- const facets = this._facets();
- if (facets && facets.length > 0) {
- params.facets = facets.join(',');
- }
+ const params = this.buildParams(page);
const queryStr = Object.entries(params)
.filter(
([_, value]) => value != null // null or undefined
@@ -173,8 +198,13 @@ export const SearchaliciousSearchMixin = >(
)
.sort() // for perdictability in tests !
.join('&');
-
- return `${baseUrl}/search?${queryStr}`;
+ return {
+ searchUrl: `${baseUrl}/search?${queryStr}`,
+ q: queryStr,
+ params,
+ // this will help update browser history
+ history: this.buildHistoryParams(params),
+ };
}
// connect to our specific events
@@ -189,6 +219,9 @@ export const SearchaliciousSearchMixin = >(
this.addEventHandler(SearchaliciousEvents.CHANGE_PAGE, (event) =>
this._handleChangePage(event)
);
+ this.addEventHandler(SearchaliciousEvents.LAUNCH_FIRST_SEARCH, (event) =>
+ this.search((event as CustomEvent)?.detail[HistorySearchParams.PAGE])
+ );
}
// connect to our specific events
override disconnectedCallback() {
@@ -200,6 +233,17 @@ export const SearchaliciousSearchMixin = >(
this.removeEventHandler(SearchaliciousEvents.CHANGE_PAGE, (event) =>
this._handleChangePage(event)
);
+ this.removeEventHandler(
+ SearchaliciousEvents.LAUNCH_FIRST_SEARCH,
+ (event) =>
+ this.search((event as CustomEvent)?.detail[HistorySearchParams.PAGE])
+ );
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ override firstUpdated(changedProperties: Map) {
+ super.firstUpdated(changedProperties);
+ this.firstSearch();
}
/**
@@ -228,11 +272,48 @@ export const SearchaliciousSearchMixin = >(
}
}
+ /**
+ * Build the params to send to the search API
+ * @param page
+ */
+ buildParams = (page?: number): BuildParamsOutput => {
+ const queryParts = [];
+ if (this.query) {
+ queryParts.push(this.query);
+ }
+ const facetsFilters = this._facetsFilters();
+ if (facetsFilters) {
+ queryParts.push(facetsFilters);
+ }
+ const params: {
+ q: string;
+ langs: string;
+ page_size: string;
+ page?: string;
+ index?: string;
+ facets?: string;
+ } = {
+ q: queryParts.join(' '),
+ langs: this.langs,
+ page_size: this.pageSize.toString(),
+ index: this.index,
+ };
+ if (page) {
+ params.page = page.toString();
+ }
+ if (this._facets().length > 0) {
+ params.facets = this._facets().join(FACETS_DIVIDER);
+ }
+ return params;
+ };
+
/**
* Launching search
*/
async search(page?: number) {
- const response = await fetch(this._searchUrl(page));
+ const {searchUrl, history} = this._searchUrl(page);
+ setCurrentURLHistory(history);
+ const response = await fetch(searchUrl);
// FIXME data should be typed…
const data = await response.json();
this._results = data.hits;
diff --git a/frontend/src/search-facets.ts b/frontend/src/search-facets.ts
index 32f7f37c..e6532b66 100644
--- a/frontend/src/search-facets.ts
+++ b/frontend/src/search-facets.ts
@@ -8,6 +8,7 @@ import {SearchaliciousTermsMixin} from './mixins/suggestions-ctl';
import {getTaxonomyName} from './utils/taxonomies';
import {SearchActionMixin} from './mixins/search-action';
import {FACET_TERM_OTHER} from './utils/constants';
+import {QueryOperator} from './utils/enums';
interface FacetsInfos {
[key: string]: FacetInfo;
@@ -66,6 +67,12 @@ export class SearchaliciousFacets extends SearchActionMixin(
) as SearchaliciousFacet[];
}
+ setSelectedTermsByFacet(selectedTermsByFacet: Record) {
+ this._facetNodes().forEach((node) => {
+ node.setSelectedTerms(selectedTermsByFacet[node.name]);
+ });
+ }
+
/**
* Names of facets we need to query,
* this is the names of contained facetNodes.
@@ -82,6 +89,12 @@ export class SearchaliciousFacets extends SearchActionMixin(
.filter(stringGuard);
}
+ setSelectedTerms(terms: string[]) {
+ this._facetNodes().forEach((node) => {
+ node.setSelectedTerms(terms);
+ });
+ }
+
/**
* As a result is received, we dispatch facets values to facetNodes
* @param event search result event
@@ -148,6 +161,13 @@ export class SearchaliciousFacet extends LitElement {
`reset not implemented: implement in sub class with submit ${submit}`
);
};
+ setSelectedTerms(terms: string[]) {
+ throw new Error(
+ `setSelectedTerms not implemented: implement in sub class ${terms.join(
+ ', '
+ )}`
+ );
+ }
override render() {
if (this.infos) {
@@ -227,6 +247,18 @@ export class SearchaliciousTermsFacet extends SearchActionMixin(
this._launchSearchWithDebounce();
}
+ /**
+ * Set the selected terms from an array of terms
+ * This is used to restore the state of the facet
+ * @param terms
+ */
+ override setSelectedTerms(terms?: string[]) {
+ this.selectedTerms = {};
+ for (const term of terms ?? []) {
+ this.selectedTerms[term] = true;
+ }
+ }
+
/**
* Create the search term based upon the selected terms
*/
@@ -241,7 +273,7 @@ export class SearchaliciousTermsFacet extends SearchActionMixin(
if (values.length === 0) {
return undefined;
}
- let orValues = values.join(' OR ');
+ let orValues = values.join(QueryOperator.OR);
if (values.length > 1) {
orValues = `(${orValues})`;
}
diff --git a/frontend/src/test/search-bar_test.ts b/frontend/src/test/search-bar_test.ts
index 270c29cc..df7c687a 100644
--- a/frontend/src/test/search-bar_test.ts
+++ b/frontend/src/test/search-bar_test.ts
@@ -72,7 +72,7 @@ suite('searchalicious-bar', () => {
input!.dispatchEvent(new Event('input'));
const bar = el as SearchaliciousBar;
assert.equal(
- (bar as any)['_searchUrl'](),
+ (bar as any)['_searchUrl']().searchUrl,
'/search?index=foo&langs=en&page_size=10&q=test'
);
});
diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts
index c27d4be8..44cf2d62 100644
--- a/frontend/src/utils/constants.ts
+++ b/frontend/src/utils/constants.ts
@@ -1 +1,3 @@
export const FACET_TERM_OTHER = '--other--';
+
+export const FACETS_DIVIDER = ',';
diff --git a/frontend/src/utils/enums.ts b/frontend/src/utils/enums.ts
index 5c51ebd4..b4ff3475 100644
--- a/frontend/src/utils/enums.ts
+++ b/frontend/src/utils/enums.ts
@@ -7,6 +7,7 @@ export enum SearchaliciousEvents {
CHANGE_PAGE = 'searchalicious-change-page',
AUTOCOMPLETE_SUBMIT = 'searchalicious-autocomplete-submit',
AUTOCOMPLETE_INPUT = 'searchalicious-autocomplete-input',
+ LAUNCH_FIRST_SEARCH = 'searchalicious-launch-first-search',
}
/**
@@ -15,3 +16,11 @@ export enum SearchaliciousEvents {
export enum BasicEvents {
CLICK = 'click',
}
+
+/**
+ * This enum defines the possible operators for the search query
+ */
+export enum QueryOperator {
+ AND = ' AND ',
+ OR = ' OR ',
+}
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
new file mode 100644
index 00000000..d71f3f98
--- /dev/null
+++ b/frontend/src/utils/index.ts
@@ -0,0 +1,26 @@
+/**
+ * Filter an object by keys
+ * @param obj
+ * @param keys
+ * @return a new object with keys matching filter and corresponding values.
+ */
+export const filterObjectByKeys = (obj: T, keys: string[]): Partial => {
+ const newObj: Partial = {};
+ for (const key of keys) {
+ const typedKey = key as keyof T;
+ if (obj[typedKey]) {
+ newObj[typedKey] = obj[typedKey];
+ }
+ }
+ return newObj;
+};
+
+/**
+ * Check if a value is null or undefined
+ * @param value
+ */
+export const isNullOrUndefined = (
+ value: unknown
+): value is null | undefined => {
+ return value === null || value === undefined;
+};
diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts
new file mode 100644
index 00000000..7741571f
--- /dev/null
+++ b/frontend/src/utils/url.ts
@@ -0,0 +1,93 @@
+/**
+ * Set the URL parameters history
+ * @param url
+ * @param params
+ */
+export const setURLHistory = (
+ url: string,
+ params: Record
+): string => {
+ const urlObj = new URL(url);
+ for (const key in params) {
+ if (params[key] === undefined) {
+ urlObj.searchParams.delete(key);
+ continue;
+ }
+ urlObj.searchParams.set(key, params[key]!);
+ }
+ const newUrl = urlObj.toString();
+ window.history.pushState({}, '', newUrl);
+ return newUrl;
+};
+
+/**
+ * Set the current URL parameters history
+ * @param params
+ */
+
+export const setCurrentURLHistory = (
+ params: Record
+): string => {
+ const url = window.location.href;
+ return setURLHistory(url, params);
+};
+
+/**
+ * Remove parenthesis from a string
+ * for example: "(test OR test2)" => "test OR test2"
+ */
+export const removeParenthesis = (value: string): string => {
+ if (value.startsWith('(') && value.endsWith(')')) {
+ return value.slice(1, -1);
+ } else {
+ return value;
+ }
+};
+
+/**
+ * Add a prefix to a string
+ * for example: addParamPrefix('test', 'off') => 'off_test'
+ */
+export const addParamPrefix = (value: string, prefix: string): string => {
+ return `${prefix}_${value}`;
+};
+
+/**
+ * Add a prefix to all keys of an object
+ * @param obj
+ * @param prefix
+ */
+export const addParamPrefixes = (
+ obj: Record,
+ prefix: string
+): Record => {
+ const newObj: Record = {};
+ for (const key in obj) {
+ newObj[addParamPrefix(key, prefix)] = obj[key];
+ }
+ return newObj;
+};
+
+/**
+ * Remove a prefix from a string
+ * for example: removeParamPrefix('off_test', 'off') => 'test'
+ */
+export const removeParamPrefix = (value: string, prefix: string): string => {
+ return value.replace(`${prefix}_`, '');
+};
+
+/**
+ * Remove a prefix from all keys of an object
+ * @param obj
+ * @param prefix
+ */
+export const removeParamPrefixes = (
+ obj: Record,
+ prefix: string
+): Record => {
+ const newObj: Record = {};
+ for (const key in obj) {
+ newObj[removeParamPrefix(key, prefix)] = obj[key];
+ }
+ return newObj;
+};