diff --git a/frontend/README.md b/frontend/README.md
index 33b8d06e..ff642f99 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -5,10 +5,11 @@ These are built using [lit](https://lit.dev) and [typescript](https://www.typesc
## Widgets
-The project is currently composed of several widgets
+The project is currently composed of several widgets.
+
+### Main widgets
* searchalicious-bar is at the core, it represent the search bar, but also handle the search logic (see searchalicious-ctl.ts)
-* searchalicious-button is a simple button to launch the search
* searchalicious-results is the component that displays the search results
* you must provide an element with attribute `slot="result"` that contains a template to display a single search result.
It's a good idea to use a `template` as enclosing element with `style="display: none"`,
@@ -23,6 +24,25 @@ The project is currently composed of several widgets
* searchalicious-facets is a container for facets (helpers to filter search results)
* it must contains some actual facets
* it will influence the search adding filters
+* searchalicious-sort is a button to choose a sort order
+ * you must add searchalicious-sort-field elements inside to add sort options
+ * with a field= to indicate the field
+ * the label is the text inside the element
+ * you can add element to slot `label` to change the label
+
+**IMPORTANT:**
+You can give a specific `name` attribute to your search bar.
+Then all other component that needs to connect with this search must use the same value in `search-name` attribute.
+This enables supporting multiple searches in the same page
+
+
+### Secondary widgets
+
+* searchalicious-button is a simple button to launch the search
+* searchalicious-count is a simple counter of the number of search results
+
+
+### Internal widgets
* searchalicious-facet-terms renders the facet for terms (list of entries, with number of docs).
* it must be in a `searchalicious-facets`
* the user can select facets to filter the search
@@ -38,8 +58,6 @@ The project is currently composed of several widgets
* it can be used to replace the default button
* searchalicious-chart renders vega chart, currently only for distribution. Requires [vega](https://vega.github.io/).
-You can give a specific `name` attribute to your search bar.
-Then all other component that needs to connect with this search must use the same value in `search-name` attribute
## Explanation on code structure
diff --git a/frontend/public/off.html b/frontend/public/off.html
index cddf65a3..69d93fa2 100644
--- a/frontend/public/off.html
+++ b/frontend/public/off.html
@@ -11,6 +11,10 @@
/>
+
@@ -182,72 +294,49 @@
-
- swap_vert
- Relevance
-
-
+
+ Sort by ▿
+ Most scanned products
+ Products with the best Eco-Score
+ Products with the best Nutri-Score
+ Recently added products
+ Recently modified products
+
-
+
+
+
+
diff --git a/frontend/src/mixins/history.ts b/frontend/src/mixins/history.ts
index bed42cf8..aa431e96 100644
--- a/frontend/src/mixins/history.ts
+++ b/frontend/src/mixins/history.ts
@@ -8,6 +8,7 @@ import {isNullOrUndefined} from '../utils';
import {BuildParamsOutput} from './search-ctl';
import {property} from 'lit/decorators.js';
import {QueryOperator} from '../utils/enums';
+import {SearchaliciousSort} from '../search-sort';
import {SearchaliciousFacets} from '../search-facets';
import {Constructor} from './utils';
@@ -17,15 +18,21 @@ export type SearchaliciousHistoryInterface = {
_currentPage?: number;
_facetsNodes: () => SearchaliciousFacets[];
_facetsFilters: () => string;
+ _sortElement: () => SearchaliciousSort | null;
convertHistoryParamsToValues: (params: URLSearchParams) => HistoryOutput;
setValuesFromHistory: (values: HistoryOutput) => void;
buildHistoryParams: (params: BuildParamsOutput) => HistoryParams;
setParamFromUrl: () => {launchSearch: boolean; values: HistoryOutput};
};
+/**
+ * A set of values that can be deduced from parameters,
+ * and are easy to use to set search components to corresponding values
+ */
export type HistoryOutput = {
query?: string;
page?: number;
+ sortOptionId?: string;
selectedTermsByFacet?: Record;
};
/**
@@ -35,6 +42,7 @@ export enum HistorySearchParams {
QUERY = 'q',
FACETS_FILTERS = 'facetsFilters',
PAGE = 'page',
+ SORT_BY = 'sort_by',
}
// name of search params as an array (to ease iteration)
@@ -69,6 +77,12 @@ const HISTORY_VALUES: Record<
query: history.q,
};
},
+ [HistorySearchParams.SORT_BY]: (history) => {
+ // in sort by we simply put the sort option id, so it's trivial
+ return {
+ sortOptionId: history.sort_by,
+ };
+ },
[HistorySearchParams.FACETS_FILTERS]: (history) => {
if (!history.facetsFilters) {
return {};
@@ -99,22 +113,19 @@ export const SearchaliciousHistoryMixin = >(
superClass: T
) => {
class SearchaliciousHistoryMixinClass extends superClass {
- /**
- * Query that will be sent to searchalicious
- */
+ // stub methods really defined in search-ctl
@property({attribute: false})
query = '';
-
- /**
- * The name of this search
- */
@property()
name = 'searchalicious';
+ // stub methods defined in search-ctl
+ _sortElement = (): SearchaliciousSort | null => {
+ throw new Error('Method not implemented.');
+ };
_facetsNodes = (): SearchaliciousFacets[] => {
throw new Error('Method not implemented.');
};
-
_facetsFilters = (): string => {
throw new Error('Method not implemented.');
};
@@ -130,6 +141,7 @@ export const SearchaliciousHistoryMixin = >(
Object.fromEntries(params),
this.name
);
+ // process each entry using it's specific function in HISTORY_VALUES
for (const key of SEARCH_PARAMS) {
Object.assign(values, HISTORY_VALUES[key](history));
}
@@ -143,6 +155,8 @@ export const SearchaliciousHistoryMixin = >(
*/
setValuesFromHistory = (values: HistoryOutput) => {
this.query = values.query ?? '';
+ this._sortElement()?.setSortOptionById(values.sortOptionId);
+ // set facets terms using linked facets nodes
if (values.selectedTermsByFacet) {
this._facetsNodes().forEach((facets) =>
facets.setSelectedTermsByFacet(values.selectedTermsByFacet!)
@@ -156,14 +170,19 @@ export const SearchaliciousHistoryMixin = >(
* @param params
*/
buildHistoryParams = (params: BuildParamsOutput) => {
- return addParamPrefixes(
- {
- [HistorySearchParams.QUERY]: this.query,
- [HistorySearchParams.FACETS_FILTERS]: this._facetsFilters(),
- [HistorySearchParams.PAGE]: params.page,
- },
- this.name
- ) as HistoryParams;
+ const urlParams: Record = {
+ [HistorySearchParams.QUERY]: this.query,
+ [HistorySearchParams.SORT_BY]: this._sortElement()?.getSortOptionId(),
+ [HistorySearchParams.FACETS_FILTERS]: this._facetsFilters(),
+ [HistorySearchParams.PAGE]: params.page,
+ };
+ // remove empty elements
+ Object.keys(urlParams).forEach((key) => {
+ if (isNullOrUndefined(urlParams[key])) {
+ delete urlParams[key];
+ }
+ });
+ return addParamPrefixes(urlParams, this.name) as HistoryParams;
};
/**
diff --git a/frontend/src/mixins/search-ctl.ts b/frontend/src/mixins/search-ctl.ts
index 3f04a82e..9373d743 100644
--- a/frontend/src/mixins/search-ctl.ts
+++ b/frontend/src/mixins/search-ctl.ts
@@ -12,6 +12,7 @@ import {
SearchResultDetail,
} from '../events';
import {Constructor} from './utils';
+import {SearchaliciousSort} from '../search-sort';
import {SearchaliciousFacets} from '../search-facets';
import {setCurrentURLHistory} from '../utils/url';
import {FACETS_DIVIDER} from '../utils/constants';
@@ -115,6 +116,27 @@ export const SearchaliciousSearchMixin = >(
@state()
_count?: number;
+ /**
+ * @returns the sort element linked to this search ctl
+ */
+ override _sortElement = (): SearchaliciousSort | null => {
+ let sortElement: SearchaliciousSort | null = null;
+ document.querySelectorAll(`searchalicious-sort`).forEach((item) => {
+ const sortElementItem = item as SearchaliciousSort;
+ if (sortElementItem.searchName == this.name) {
+ if (sortElement !== null) {
+ console.warn(
+ `searchalicious-sort element with search-name ${this.name} already exists, ignoring`
+ );
+ } else {
+ sortElement = sortElementItem;
+ }
+ }
+ });
+
+ return sortElement;
+ };
+
/**
* Wether search should be launched at page load
*/
@@ -298,9 +320,16 @@ export const SearchaliciousSearchMixin = >(
page_size: this.pageSize.toString(),
index: this.index,
};
+ // sorting parameters
+ const sortElement = this._sortElement();
+ if (sortElement) {
+ Object.assign(params, sortElement.getSortParameters());
+ }
+ // page
if (page) {
params.page = page.toString();
}
+ // facets
if (this._facets().length > 0) {
params.facets = this._facets().join(FACETS_DIVIDER);
}
diff --git a/frontend/src/search-a-licious.ts b/frontend/src/search-a-licious.ts
index 2eda2c77..36c0f9e5 100644
--- a/frontend/src/search-a-licious.ts
+++ b/frontend/src/search-a-licious.ts
@@ -5,6 +5,7 @@ export {SearchaliciousPages} from './search-pages';
export {SearchaliciousFacets} from './search-facets';
export {SearchaliciousResults} from './search-results';
export {SearchCount} from './search-count';
+export {SearchaliciousSort, SearchaliciousSortField} from './search-sort';
export {SearchaliciousAutocomplete} from './search-autocomplete';
export {SearchaliciousSecondaryButton} from './secondary-button';
export {SearchaliciousButtonTransparent} from './button-transparent';
diff --git a/frontend/src/search-sort.ts b/frontend/src/search-sort.ts
new file mode 100644
index 00000000..f365f446
--- /dev/null
+++ b/frontend/src/search-sort.ts
@@ -0,0 +1,278 @@
+import {css, LitElement, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+
+import {SearchActionMixin} from './mixins/search-action';
+import {EventRegistrationMixin} from './event-listener-setup';
+import {SearchaliciousEvents} from './utils/enums';
+
+/**
+ * A component to enable user to choose a search order
+ *
+ * It must contains searchalicious-sort-options
+ * @slot label - rendered on the button
+ */
+@customElement('searchalicious-sort')
+export class SearchaliciousSort extends SearchActionMixin(
+ EventRegistrationMixin(LitElement)
+) {
+ static override styles = css`
+ .options {
+ list-style: none;
+ padding: 0.3em;
+ margin: 0;
+ background-color: var(--sort-options-background-color, #ffffff);
+ position: absolute;
+ }
+ `;
+
+ /**
+ * Wether to relaunch search on sort change
+ */
+ @property({attribute: 'auto-refresh', type: Boolean})
+ autoRefresh = false;
+
+ /**
+ * Marker of selected items
+ */
+ @property({attribute: 'selected-marker'})
+ selectedMarker = '▶';
+
+ @state()
+ showOptions = false;
+
+ /**
+ * return sort options elements
+ */
+ sortOptions() {
+ return Array.from(this.children ?? []).filter((node) =>
+ node.nodeName.startsWith('SEARCHALICIOUS-SORT-')
+ ) as Array;
+ }
+
+ /**
+ * Sort option currently selected
+ */
+ currentSortOption() {
+ return (this.sortOptions().filter((node) => node.selected) ?? [
+ undefined,
+ ])[0];
+ }
+
+ /**
+ * Set selected sort option
+ */
+ setSortOption(option: SearchaliciousSortOption) {
+ this.sortOptions().forEach(
+ (node) => ((node as SearchaliciousSortOption).selected = node === option)
+ );
+ }
+
+ getSortOptionId() {
+ return this.currentSortOption()?.id;
+ }
+
+ /**
+ * set selected sort option by using it's id.
+ *
+ * If optionId is undefined, unselect all
+ */
+ setSortOptionById(optionId: string | undefined) {
+ this.sortOptions().forEach(
+ (node) =>
+ ((node as SearchaliciousSortOption).selected = node.id === optionId)
+ );
+ }
+
+ /**
+ * Duplicate our selected marker to all children sort options that did not have it yet
+ */
+ assignSelectedMarker() {
+ this.sortOptions().forEach((node) => {
+ if (!node.selectedMarker) {
+ node.selectedMarker = this.selectedMarker;
+ }
+ });
+ }
+
+ /**
+ * Get sort parameters of selected option or return an empty Object
+ */
+ getSortParameters() {
+ const option = this.currentSortOption();
+ return option ? option.getSortParameters() : {};
+ }
+
+ /**
+ * sub part to render options when we show them
+ */
+ _renderOptions() {
+ return html`
+
+ `;
+ }
+
+ override render() {
+ return html`
+
+ Sort by ▾
+
+ ${this.showOptions ? this._renderOptions() : nothing}
+ `;
+ }
+
+ /**
+ * Show or hide option as we click on the button
+ */
+ _onClick() {
+ this.showOptions = !this.showOptions;
+ }
+
+ /**
+ * React to a sort option being selected
+ * * set currently selected
+ * * hide options
+ * * eventually launch search
+ * @param event
+ */
+ _handleSelected(event: Event) {
+ const option = event.target as SearchaliciousSortOption;
+ this.setSortOption(option);
+ this.showOptions = false;
+ if (this.autoRefresh) {
+ this._launchSearch();
+ }
+ }
+
+ /**
+ * Connect option selection event handlers.
+ */
+ override connectedCallback() {
+ super.connectedCallback();
+ this.addEventHandler(SearchaliciousEvents.SORT_OPTION_SELECTED, (event) =>
+ this._handleSelected(event)
+ );
+ }
+ // disconnect our specific events
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ this.removeEventHandler(
+ SearchaliciousEvents.SORT_OPTION_SELECTED,
+ (event) => this._handleSelected(event)
+ );
+ }
+}
+
+/**
+ * A sort option component, this is a base class
+ *
+ * @slot - the content is rendered as is and is considered the content.
+ */
+export class SearchaliciousSortOption extends LitElement {
+ static override styles = css`
+ .sort-option {
+ display: block;
+ margin: 0 0.5rem;
+ }
+ .sort-option a {
+ text-decoration: none;
+ color: var(--sort-options-color, #000000);
+ }
+ .sort-option:hover {
+ background-color: var(--sort-options-hover-background-color, #dddddd);
+ }
+ `;
+
+ /**
+ * If the value is selected.
+ * Only one value should be selected at once in a sort component.
+ */
+ @property({type: Boolean})
+ selected = false;
+
+ @property()
+ selectedMarker = '';
+
+ /**
+ * Eventually gives a default value to id
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ override firstUpdated(changedProperties: Map) {
+ super.firstUpdated(changedProperties);
+ if (!this.id) {
+ this.id = this.getDefaultId();
+ }
+ }
+
+ /**
+ * Create a sensible default id for this sort option
+ */
+ getDefaultId(): string {
+ throw new Error('Not implemented');
+ }
+
+ /**
+ * This is the method that should return the sort paratemetrs
+ */
+ getSortParameters() {
+ throw new Error('Not implemented');
+ }
+
+ /**
+ * Rendering is a simple li element
+ */
+ override render() {
+ return html`
+
+
+ ${this.selected
+ ? html`${this.selectedMarker} `
+ : nothing}
+
+
+
+ `;
+ }
+
+ _onClick(event: Event) {
+ this.dispatchEvent(
+ new CustomEvent(SearchaliciousEvents.SORT_OPTION_SELECTED, {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ event.preventDefault();
+ return false;
+ }
+}
+
+@customElement('searchalicious-sort-field')
+export class SearchaliciousSortField extends SearchaliciousSortOption {
+ /**
+ * The field name we want to sort on
+ */
+ @property()
+ field = '';
+
+ /** id defaults to field- */
+ override getDefaultId() {
+ return `field-${this.field}`;
+ }
+
+ override getSortParameters() {
+ return {
+ sort_by: this.field,
+ };
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'searchalicious-sort': SearchaliciousSort;
+ 'searchalicious-sort-field': SearchaliciousSortField;
+ }
+}
diff --git a/frontend/src/utils/enums.ts b/frontend/src/utils/enums.ts
index b4ff3475..00cdccd2 100644
--- a/frontend/src/utils/enums.ts
+++ b/frontend/src/utils/enums.ts
@@ -5,8 +5,12 @@ export enum SearchaliciousEvents {
LAUNCH_SEARCH = 'searchalicious-search',
NEW_RESULT = 'searchalicious-result',
CHANGE_PAGE = 'searchalicious-change-page',
+ // events for autocomplete selection
AUTOCOMPLETE_SUBMIT = 'searchalicious-autocomplete-submit',
AUTOCOMPLETE_INPUT = 'searchalicious-autocomplete-input',
+ // events for sort option selection
+ SORT_OPTION_SELECTED = 'searchalicious-sort-option-selected',
+ // askin for first search launch is a specific event
LAUNCH_FIRST_SEARCH = 'searchalicious-launch-first-search',
}