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
4 changes: 4 additions & 0 deletions assets/controllers.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"enabled": true,
"fetch": "lazy"
},
"modal-prefill": {
"enabled": true,
"fetch": "lazy"
},
"csrf-protection": {
"enabled": true,
"fetch": "eager"
Expand Down
108 changes: 108 additions & 0 deletions assets/controllers/manual_entry_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Controller } from '@hotwired/stimulus';

interface TomSelectOption {
[key: string]: unknown;
}

interface TomSelectLike {
options: Record<string, TomSelectOption>;
addOption: (option: TomSelectOption) => void;
removeOption: (value: string, silent?: boolean) => void;
refreshOptions: (triggerDropdown?: boolean) => void;
clear: (silent?: boolean) => void;
getValue: () => string | string[];
}

interface SelectWithTomSelect extends HTMLSelectElement {
tomselect?: TomSelectLike;
}

// Narrows the manual-entry modal's project list to a single client, for the
// trigger on a client row (which has no one project to pre-select).
//
// The generic "seed a field from the trigger" behaviour lives in the platform's
// `modal-prefill` controller; only the project => client relationship is
// SolidTrack's, so it stays here. The two are mutually exclusive in practice —
// a trigger names either a project or a client, never both.
//
// The narrowing has to go through TomSelect rather than the server because the
// project field sits behind `data-live-ignore`, so a re-render cannot replace
// its options. The project => client map is published by the component as
// `data-project-clients`.

/* stimulusFetch: 'lazy' */
export default class extends Controller {
private removed: TomSelectOption[] = [];

private onShow = (event: Event) => this.narrow(event as Event & { relatedTarget?: HTMLElement });

private onHidden = () => this.restore();

connect() {
this.element.addEventListener('show.bs.modal', this.onShow);
this.element.addEventListener('hidden.bs.modal', this.onHidden);
}

disconnect() {
this.element.removeEventListener('show.bs.modal', this.onShow);
this.element.removeEventListener('hidden.bs.modal', this.onHidden);
}

private narrow(event: Event & { relatedTarget?: HTMLElement }) {
this.restore();

const clientId = event.relatedTarget?.dataset?.prefillClient;
if (!clientId) {
return;
}

const select = this.projectField();
const tomSelect = select?.tomselect;
if (!tomSelect) {
return;
}

const projectClients = this.projectClients();

Object.keys(tomSelect.options).forEach((value) => {
if (projectClients[value] !== clientId) {
this.removed.push(tomSelect.options[value]);
tomSelect.removeOption(value, true);
}
});

// A project carried over from a previous open may no longer be on offer.
if (this.removed.length > 0) {
tomSelect.clear(true);
tomSelect.refreshOptions(false);
}
}

private restore() {
if (this.removed.length === 0) {
return;
}

const tomSelect = this.projectField()?.tomselect;
if (tomSelect) {
this.removed.forEach((option) => tomSelect.addOption(option));
tomSelect.refreshOptions(false);
}

this.removed = [];
}

private projectField(): SelectWithTomSelect | null {
return this.element.querySelector<SelectWithTomSelect>('select[name$="[project]"]');
}

private projectClients(): Record<string, string> {
const holder = this.element.querySelector<HTMLElement>('[data-project-clients]');

try {
return JSON.parse(holder?.dataset.projectClients ?? '{}');
} catch {
return {};
}
}
}
Loading
Loading