Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions assets/controllers/elements/datatables/datatables_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,52 @@ export default class extends Controller {
return key;
}

/**
* Returns true, if the URL of the table requests a certain ordering (e.g. the one chosen in the quick search).
* This ordering takes precedence over the saved one and must not replace it.
* @returns {boolean}
*/
hasRequestedOrder() {
const url = this.element.dataset.dtUrl ?? '';
const query = url.includes('?') ? url.substring(url.indexOf('?') + 1) : '';

return new URLSearchParams(query).has('sort');
}

stateSaveCallback(settings, data) {
if (this.hasRequestedOrder()) {
//Keep the ordering the user has chosen for the tables, instead of saving the one requested by the URL
const saved_order = this._loadSavedState()?.order;
if (saved_order) {
data.order = saved_order;
} else {
delete data.order;
}
}

localStorage.setItem( this.getStateSaveKey(), JSON.stringify(data) );
}

stateLoadCallback() {
_loadSavedState() {
const json = localStorage.getItem(this.getStateSaveKey());
if(json === null || json === undefined) {
return null;
}

const data = JSON.parse(json);
return JSON.parse(json);
}

stateLoadCallback() {
const data = this._loadSavedState();

if (data) {
//Do not save the start value (current page), as we want to always start at the first page on a page reload
delete data.start;

//The ordering requested by the URL takes precedence over the saved one
if (this.hasRequestedOrder()) {
delete data.order;
}
}

return data;
Expand All @@ -100,8 +131,9 @@ export default class extends Controller {

//Add initial_order info to the settings, so that the order on the initial page load is the one saved in the state
const saved_state = this.stateLoadCallback();
if (saved_state !== null) {
const raw_order = saved_state.order;
if (saved_state !== null && !this.hasRequestedOrder()) {
//The state has no order, if it was only saved on a page with an ordering requested by the URL
const raw_order = saved_state.order ?? [];

settings.initial_order = raw_order.map((order) => {
//Skip if direction is empty, as this is the default, otherwise datatables server is confused when the order is sent in the request, but the initial order is set to an empty direction
Expand Down
129 changes: 125 additions & 4 deletions assets/controllers/elements/part_search_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,27 @@ import {
} from '../../translator';


//The key under which the chosen ordering of the results is remembered in the local storage
const SORT_STORAGE_KEY = 'part_search_sort';

//The orderings which can be chosen in the results dropdown (key as understood by the server and the icon of the toggle)
const SORT_OPTIONS = [
{key: 'name', icon: 'fa-font'},
{key: 'manufacturer', icon: 'fa-industry'},
{key: 'supplier', icon: 'fa-truck'},
{key: 'added', icon: 'fa-calendar-plus'},
{key: 'modified', icon: 'fa-pen'},
{key: 'top_category', icon: 'fa-sitemap'},
{key: 'category', icon: 'fa-tags'},
];

/**
* This controller is responsible for the search fields in the navbar and the homepage.
* It uses the Algolia Autocomplete library to provide a fast and responsive search.
*/
export default class extends Controller {

static targets = ["input"];
static targets = ["input", "sort", "sortDir"];

_autocomplete;

Expand Down Expand Up @@ -86,6 +100,101 @@ export default class extends Controller {
return options;
}

/**
* Returns the ordering chosen for the results, or null if the default ordering is used.
* It is read from the local storage every time, so all search fields on the page share it.
* @returns {{key: string, desc: boolean}|null}
*/
_getSort() {
let value = null;
try {
value = localStorage.getItem(SORT_STORAGE_KEY);
} catch (e) {
// Without local storage the default ordering is used
}
if (!value) {
return null;
}

const [key, direction] = value.split(':');
if (!SORT_OPTIONS.some((option) => option.key === key)) {
return null;
}

return {key: key, desc: direction === 'desc'};
}

/**
* Returns the icon of the chosen ordering, which marks the value the results are ordered by.
* @returns {string}
*/
_getSortIcon() {
const sort = this._getSort();
return (sort && SORT_OPTIONS.find((option) => option.key === sort.key)?.icon) || 'fa-sort';
}

/**
* Cycles the given ordering: ascending on the first click, descending on the second and back to the default
* ordering on the third one. Only one ordering can be active at a time.
* @param {string} key
*/
_toggleSort(key) {
const current = this._getSort();

try {
if (!current || current.key !== key) {
localStorage.setItem(SORT_STORAGE_KEY, key + ':asc');
} else if (!current.desc) {
localStorage.setItem(SORT_STORAGE_KEY, key + ':desc');
} else {
localStorage.removeItem(SORT_STORAGE_KEY);
}
} catch (e) {
return;
}

this._autocomplete.refresh();
}

/**
* Put the chosen ordering into the form, so the search page is sorted the same way as the dropdown.
*/
_updateSortInputs() {
if (!this.hasSortTarget || !this.hasSortDirTarget) {
return;
}

const sort = this._getSort();
this.sortTarget.disabled = this.sortDirTarget.disabled = !sort;
this.sortTarget.value = sort ? sort.key : '';
this.sortDirTarget.value = sort && sort.desc ? 'desc' : 'asc';
}

/**
* Renders the small toggles used to choose the ordering of the results.
*/
_renderSortToggles(html) {
const sort = this._getSort();

return html`<span class="aa-SourceHeaderSort" role="group" aria-label="${trans("search.sort.label")}">
${SORT_OPTIONS.map((option) => {
const active = sort && sort.key === option.key;
const title = trans("search.sort.label") + ': ' + trans("search.sort." + option.key)
+ (active ? ' (' + trans(sort.desc ? "search.sort.descending" : "search.sort.ascending") + ')' : '');

//The mousedown default is prevented, so the search input keeps the focus and the dropdown stays open
return html`<button type="button" tabindex="-1" key="${option.key}"
class="aa-SourceHeaderSortToggle ${active ? 'aa-SourceHeaderSortToggle--active' : ''}"
title="${title}" aria-label="${title}" aria-pressed="${active ? 'true' : 'false'}"
onMouseDown="${(event) => event.preventDefault()}"
onClick="${(event) => { event.preventDefault(); this._toggleSort(option.key); }}">
<i class="fa-solid fa-fw ${option.icon}"></i>
<i class="fa-solid aa-SourceHeaderSortArrow ${active && sort.desc ? 'fa-arrow-down' : 'fa-arrow-up'}"></i>
</button>`;
})}
</span>`;
}

connect() {
document.addEventListener('keydown', this._onGlobalKeydown);
this._updatePlaceholders();
Expand Down Expand Up @@ -215,6 +324,7 @@ export default class extends Controller {
}

input.value = state.query;
that._updateSortInputs();
input.form.requestSubmit();
},

Expand All @@ -227,10 +337,19 @@ export default class extends Controller {
getItems() {
let url = base_url.replace('__QUERY__', encodeURIComponent(query));

// Pass the search options, so the selected fields and matching modes also apply to the dropdown
// Pass the search options, so regex and extensive matching also work in the dropdown
const options = that._getSearchOptions();
if (options) {
url += (url.includes('?') ? '&' : '?') + options.toString();
const params = new URLSearchParams(options ?? undefined);

// And the chosen ordering of the results
const sort = that._getSort();
if (sort) {
params.set('sort', sort.key);
params.set('sort_dir', sort.desc ? 'desc' : 'asc');
}

if (params.toString() !== '') {
url += (url.includes('?') ? '&' : '?') + params.toString();
}

const data = fetch(url)
Expand Down Expand Up @@ -259,6 +378,7 @@ export default class extends Controller {
templates: {
header({ html }) {
return html`<span class="aa-SourceHeaderTitle">${trans("part.labelp")}</span>
${that._renderSortToggles(html)}
<div class="aa-SourceHeaderLine" />`;
},
item({item, components, html}) {
Expand All @@ -280,6 +400,7 @@ export default class extends Controller {
${components.Highlight({hit: item, attribute: 'description'})}
${item.category ? html`<p class="m-0"><span class="fa-solid fa-tags fa-fw"></span>${components.Highlight({hit: item, attribute: 'category'})}</p>` : ""}
${item.footprint ? html`<p class="m-0"><span class="fa-solid fa-microchip fa-fw"></span>${components.Highlight({hit: item, attribute: 'footprint'})}</p>` : ""}
${item.sort_value ? html`<p class="m-0 aa-ItemSortValue"><span class="fa-solid ${that._getSortIcon()} fa-fw"></span>${item.sort_value}</p>` : ""}
</div>
</div>
</div>
Expand Down
50 changes: 50 additions & 0 deletions assets/css/components/autocomplete_bootstrap_theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,56 @@ body {
z-index: calc(var(--aa-base-z-index) - 1);
}

/* The toggles to choose the ordering of the results, shown at the right of the header */
.aa-SourceHeaderSort {
background: var(--bs-body-bg);
display: inline-flex;
float: right;
gap: 4px;
padding: 0 0 0 var(--aa-spacing-half);
position: relative;
z-index: 9999;
z-index: var(--aa-base-z-index);
}
/* The toggle rules are scoped to .aa-Panel, as the ".aa-Panel button" reset above would override them otherwise */
.aa-Panel .aa-SourceHeaderSortToggle {
background: none;
border: 1px solid transparent;
border-radius: 1em;
color: var(--bs-secondary-color);
cursor: pointer;
font-size: 1em;
line-height: 1.6;
min-width: 2.2em;
padding: 0.1em 0.55em;
white-space: nowrap;
}
.aa-Panel .aa-SourceHeaderSortToggle:hover {
border-color: rgba(var(--bs-primary-rgb), 0.5);
color: rgba(var(--bs-primary-rgb), 1.0);
}
.aa-Panel .aa-SourceHeaderSortToggle--active,
.aa-Panel .aa-SourceHeaderSortToggle--active:hover {
background: rgba(var(--bs-primary-rgb), 1.0);
border-color: rgba(var(--bs-primary-rgb), 1.0);
/* Not the body background: it is dark in dark mode and unreadable on the primary color */
color: #fff;
}
/* The direction arrow always takes its place, so the toggles do not move when one of them is chosen */
.aa-SourceHeaderSortArrow {
font-size: 0.75em;
margin-left: 0.15em;
visibility: hidden;
}
.aa-SourceHeaderSortToggle--active .aa-SourceHeaderSortArrow {
visibility: visible;
}

/* The value a result is ordered by (supplier, manufacturer, date, ...) */
.aa-ItemSortValue {
color: rgba(var(--bs-primary-rgb), 1.0);
}

.aa-SourceFooterSeeAll {
background: linear-gradient(180deg, var(--bs-body-bg), rgba(128, 126, 163, 0.14));
border: 1px solid var(--bs-secondary-color);
Expand Down
10 changes: 10 additions & 0 deletions src/Controller/PartListsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use App\DataTables\ErrorDataTable;
use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Filters\PartSearchSort;
use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
Expand All @@ -40,6 +41,7 @@
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
Expand Down Expand Up @@ -313,6 +315,13 @@ public function showSearch(Request $request, DataTableFactory $dataTable): Respo
{
$searchFilter = PartSearchFilter::fromRequest($request);

//If an ordering was chosen in the quick search, show the table sorted by the matching column (if there is one)
$order = null;
$order_column = PartSearchSort::fromRequest($request)?->getTableColumn();
if ($order_column !== null) {
$order = [$order_column, PartSearchSort::directionFromRequest($request) === \SortDirection::Descending ? DataTable::SORT_DESCENDING : DataTable::SORT_ASCENDING];
}

return $this->showListWithFilter($request,
'parts/lists/search_list.html.twig',
null,
Expand All @@ -323,6 +332,7 @@ public function showSearch(Request $request, DataTableFactory $dataTable): Respo
],
[
'search' => $searchFilter,
'order' => $order,
]
);
}
Expand Down
Loading
Loading