PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/ticket-kb-suggest_controller-JmOxG5b.js

js healthylifenetwork.com.au collected 2026-09-25 03:34:33 UTC 6,023 bytes, 189 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2
3/*
4 * ticket-kb-suggest — help-desk KB integration (deflection + agent-assist).
5 *
6 * Two modes (data-ticket-kb-suggest-mode-value):
7 *  - "deflect": watches the subject/message fields while a user files a ticket and
8 *    surfaces the closest KB articles ("before you file this, does one of these
9 *    answer it?"). Suggestion cards are links that open the article in a new tab.
10 *  - "assist": an agent types a keyword (or reuses the ticket subject), and each
11 *    suggestion card inserts a link to that article into the reply textarea.
12 *
13 * Retrieval is server-side and access-filtered; this controller is purely UI.
14 */
15export default class extends Controller {
16    static targets = ['input', 'query', 'panel', 'results', 'loading', 'empty', 'insert'];
17    static values = {
18        url: String,
19        token: String,
20        mode: { type: String, default: 'deflect' },
21        minChars: { type: Number, default: 5 },
22        debounce: { type: Number, default: 500 },
23    };
24
25    connect() {
26        this._timer = null;
27        this._lastQuery = '';
28    }
29
30    disconnect() {
31        if (this._timer) {
32            clearTimeout(this._timer);
33        }
34    }
35
36    // Debounced trigger bound to input events (deflect) or the query field (assist).
37    schedule() {
38        if (this._timer) {
39            clearTimeout(this._timer);
40        }
41        this._timer = setTimeout(() => this.refresh(), this.debounceValue);
42    }
43
44    // Immediate trigger (e.g. an explicit "Find articles" button in assist mode).
45    search(event) {
46        if (event) {
47            event.preventDefault();
48        }
49        this.refresh();
50    }
51
52    queryText() {
53        const sources = this.hasQueryTarget ? [this.queryTarget] : this.inputTargets;
54        return sources.map((el) => (el.value || '').trim()).filter(Boolean).join(' ').trim();
55    }
56
57    async refresh() {
58        const query = this.queryText();
59        if (query.length < this.minCharsValue) {
60            this.hidePanel();
61            return;
62        }
63        if (query === this._lastQuery) {
64            return;
65        }
66        this._lastQuery = query;
67
68        this.setLoading(true);
69        try {
70            const response = await fetch(this.urlValue, {
71                method: 'POST',
72                headers: {
73                    'Content-Type': 'application/json',
74                    'X-Requested-With': 'XMLHttpRequest',
75                },
76                body: JSON.stringify({ query, _token: this.tokenValue }),
77            });
78            const data = await response.json().catch(() => ({}));
79            if (!response.ok) {
80                this.hidePanel();
81                return;
82            }
83            this.render(Array.isArray(data.sources) ? data.sources : []);
84        } catch (e) {
85            this.hidePanel();
86        } finally {
87            this.setLoading(false);
88        }
89    }
90
91    render(sources) {
92        this.resultsTarget.replaceChildren();
93
94        if (sources.length === 0) {
95            if (this.hasEmptyTarget) {
96                this.emptyTarget.classList.remove('hidden');
97            }
98            this.showPanel();
99            return;
100        }
101
102        if (this.hasEmptyTarget) {
103            this.emptyTarget.classList.add('hidden');
104        }
105        sources.forEach((source) => this.resultsTarget.appendChild(this.buildCard(source)));
106        this.showPanel();
107    }
108
109    buildCard(source) {
110        if (this.modeValue === 'assist') {
111            return this.buildInsertCard(source);
112        }
113        return this.buildLinkCard(source);
114    }
115
116    buildLinkCard(source) {
117        const link = document.createElement('a');
118        link.href = source.url;
119        link.target = '_blank';
120        link.rel = 'noopener';
121        link.className = 'block p-3 rounded-lg border border-base-300 hover:border-primary hover:bg-base-200/50 transition-colors';
122        link.appendChild(this.cardBody(source));
123        return link;
124    }
125
126    buildInsertCard(source) {
127        const wrap = document.createElement('div');
128        wrap.className = 'flex items-center gap-2 p-3 rounded-lg border border-base-300';
129        wrap.appendChild(this.cardBody(source));
130
131        const btn = document.createElement('button');
132        btn.type = 'button';
133        btn.className = 'btn btn-xs btn-primary shrink-0';
134        btn.textContent = 'Insert';
135        btn.addEventListener('click', () => this.insertArticle(source));
136        wrap.appendChild(btn);
137        return wrap;
138    }
139
140    cardBody(source) {
141        const body = document.createElement('div');
142        body.className = 'flex-1 min-w-0';
143
144        const title = document.createElement('div');
145        title.className = 'font-medium text-sm';
146        title.textContent = source.title || 'Untitled';
147        body.appendChild(title);
148
149        if (source.abstract) {
150            const abstract = document.createElement('div');
151            abstract.className = 'text-xs opacity-70 mt-0.5 line-clamp-2';
152            abstract.textContent = source.abstract;
153            body.appendChild(abstract);
154        }
155        return body;
156    }
157
158    insertArticle(source) {
159        if (!this.hasInsertTarget) {
160            return;
161        }
162        const origin = window.location.origin;
163        const snippet = `See our article "${source.title}": ${origin}${source.url}`;
164        const field = this.insertTarget;
165        const existing = field.value || '';
166        field.value = existing ? `${existing}\n\n${snippet}` : snippet;
167        field.dispatchEvent(new Event('input', { bubbles: true }));
168        field.focus();
169    }
170
171    setLoading(isLoading) {
172        if (!this.hasLoadingTarget) {
173            return;
174        }
175        this.loadingTarget.classList.toggle('hidden', !isLoading);
176    }
177
178    showPanel() {
179        if (this.hasPanelTarget) {
180            this.panelTarget.classList.remove('hidden');
181        }
182    }
183
184    hidePanel() {
185        if (this.hasPanelTarget) {
186            this.panelTarget.classList.add('hidden');
187        }
188    }
189}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.