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 @@
- - + + 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` + + ${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', }