Sort the technician table by clicking its headers
All checks were successful
Build and push image / build (push) Successful in 15s
All checks were successful
Build and push image / build (push) Successful in 15s
Replaces the "Sort rows by" setting from the previous commit. Every header is now a button: a new column starts A to Z for names and highest first for counts, and a second click reverses it. Rows default to name A to Z, and each browser remembers its own sort in localStorage, like the sound toggle. The arrow points toward the larger end (down for A to Z or lowest first, up for highest first). The tableSort setting is removed from the server again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
872a7f17c3
commit
dc684fd7fa
@ -120,7 +120,7 @@ The full security model, its limits and the open items are in [docs/SECURITY.md]
|
|||||||
## The dashboard
|
## The dashboard
|
||||||
|
|
||||||
- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets.
|
- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets.
|
||||||
- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. A row flashes when that technician closes a ticket.
|
- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. Click a column header to sort by it, and click it again to reverse. Rows start A to Z by name; the arrow points toward the larger end (↓ is A to Z or lowest first, ↑ is highest first). Each browser remembers its own sort. A row flashes when that technician closes a ticket.
|
||||||
- **Weather**: current conditions and a 5-day forecast for `OWM_ZIP_CODE`, refreshed every 10 minutes.
|
- **Weather**: current conditions and a 5-day forecast for `OWM_ZIP_CODE`, refreshed every 10 minutes.
|
||||||
- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser.
|
- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser.
|
||||||
- **Refresh now** reruns every check immediately.
|
- **Refresh now** reruns every check immediately.
|
||||||
@ -133,7 +133,6 @@ The full security model, its limits and the open items are in [docs/SECURITY.md]
|
|||||||
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
|
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
|
||||||
- **Weather location**, from the list of campuses in `src/places.js`.
|
- **Weather location**, from the list of campuses in `src/places.js`.
|
||||||
- **Which statuses count as closed for the feed and ding.** Starts as `STATUS_NAMES`. SDP closes a resolved ticket on its own after a few days, so ticking **Resolved** gives the alert when the ticket is resolved instead. With Resolved ticked, a ticket's time in the feed is its resolve time, so the automatic close later doesn't ding again.
|
- **Which statuses count as closed for the feed and ding.** Starts as `STATUS_NAMES`. SDP closes a resolved ticket on its own after a few days, so ticking **Resolved** gives the alert when the ticket is resolved instead. With Resolved ticked, a ticket's time in the feed is its resolve time, so the automatic close later doesn't ding again.
|
||||||
- **Table order:** sort the technician table by technician name, Total open, Closed today or any status column. Counts sort busiest first. The default keeps the order of the technician list (A to Z when watching everyone), and the sorted column's header has an arrow.
|
|
||||||
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
|
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
|
||||||
|
|
||||||
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules for the status table, Closed today and the unassigned count aren't per person; they stay in `.env`.
|
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules for the status table, Closed today and the unassigned count aren't per person; they stay in `.env`.
|
||||||
|
|||||||
@ -40,7 +40,7 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
|
|||||||
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
||||||
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
||||||
- **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest.
|
- **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest.
|
||||||
- **2026-10-19:** **Table order is a saved setting (`tableSort`), sorted in the browser.** Values are `list`, `name`, `open`, `closedToday` or `status:<name>`; the server only validates the value. A status sort is saved by name and compared without case, so it survives that status having no tickets for a while (the rows then fall back to A to Z). Counts sort descending with ties by name, and there's no direction choice; add one if anyone asks. It's in Settings rather than clickable headers so a wallboard keeps its order after a reload.
|
- **2026-10-19:** **The technician table sorts by clicking its headers, remembered per browser** (`localStorage` key `sdp-table-sort`, like the sound). This replaced a short-lived `tableSort` setting in the Settings dialog (never released): headers are easier to find and switch, and a wallboard and a desk on the same account can sort differently. The default is name A to Z; a new column starts A to Z for names and highest first for counts, and a second click reverses it. Ties always go A to Z. The arrow points toward the larger end, so ↓ means A to Z or lowest first and ↑ means highest first. Skylar found the usual ↑-for-ascending convention backwards, so don't "fix" it. A saved status sort whose column has no tickets right now just sorts everyone as 0 (A to Z).
|
||||||
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
||||||
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
||||||
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
|
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
|
||||||
|
|||||||
@ -37,4 +37,4 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
|
|||||||
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
|
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
|
||||||
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
|
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
|
||||||
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
|
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
|
||||||
- Per-user table order (2026-10-19, `v2.1.1`): sort the technician table by name, Total open, Closed today or a status column. Sorting happens in the browser; only the choice is saved.
|
- Sortable technician table (2026-10-19, `v2.1.1`): click any header to sort, again to reverse. Defaults to name A to Z; remembered per browser.
|
||||||
|
|||||||
@ -313,9 +313,21 @@ th {
|
|||||||
border-bottom: 1px solid var(--rule);
|
border-bottom: 1px solid var(--rule);
|
||||||
vertical-align: bottom;
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
th[aria-sort] { color: var(--ink); }
|
/* sortable headers: a plain button that looks like the header text */
|
||||||
th[aria-sort="descending"]::after { content: " ↓"; }
|
th .sort {
|
||||||
th[aria-sort="ascending"]::after { content: " ↑"; }
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: inherit;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
th .sort:hover, th[aria-sort] { color: var(--ink); }
|
||||||
|
th .sort:focus-visible { outline: 1px solid var(--signal); outline-offset: 3px; }
|
||||||
|
/* the arrow keeps its width when empty, so a column doesn't shift when it becomes the sort */
|
||||||
|
th .arrow { display: inline-block; width: 1.2em; text-align: center; }
|
||||||
th .swatch {
|
th .swatch {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 7px;
|
width: 7px;
|
||||||
@ -702,6 +714,14 @@ function toggleSound() {
|
|||||||
}
|
}
|
||||||
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||||
|
|
||||||
|
// technician table order, from clicking its headers. Per browser like the sound, so a wallboard
|
||||||
|
// and a desk signed in as the same person can sort differently
|
||||||
|
let tableSort = { key: 'name', dir: 'asc' };
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('sdp-table-sort'));
|
||||||
|
if (saved && typeof saved.key === 'string' && (saved.dir === 'asc' || saved.dir === 'desc')) tableSort = saved;
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
let data = {
|
let data = {
|
||||||
auth: 'loading', // loading, connected or disconnected (signed out)
|
auth: 'loading', // loading, connected or disconnected (signed out)
|
||||||
tickets: [],
|
tickets: [],
|
||||||
@ -945,28 +965,48 @@ function renderStats(wl) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// rows by the user's tableSort: counts busiest first, names A to Z, ties by name.
|
// sort keys: 'name', 'open', 'closedToday' or 'status:<name>'; ties go A to Z.
|
||||||
// 'list' keeps the server's order (the technician list as entered, or A to Z)
|
// a status with no tickets right now sorts everyone as 0, so the rows fall back to A to Z
|
||||||
function sortTechs(techs, sort) {
|
function sortTechs(techs, sort) {
|
||||||
if (sort === 'list') return techs;
|
|
||||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
if (sort === 'name') return techs.slice().sort(byName);
|
const flip = sort.dir === 'desc' ? -1 : 1;
|
||||||
const status = sort.startsWith('status:') ? sort.slice(7).toLowerCase() : null;
|
if (sort.key === 'name') return techs.slice().sort((a, b) => flip * byName(a, b));
|
||||||
// counts are keyed by SDP's spelling; the saved name may differ in case
|
const status = sort.key.startsWith('status:') ? sort.key.slice(7) : null;
|
||||||
const value = t => status
|
const value = t => (status ? t.counts[status] : t[sort.key]) || 0;
|
||||||
? Object.entries(t.counts).reduce((n, [k, v]) => k.toLowerCase() === status ? n + v : n, 0)
|
return techs.slice().sort((a, b) => flip * (value(a) - value(b)) || byName(a, b));
|
||||||
: t[sort] || 0;
|
|
||||||
return techs.slice().sort((a, b) => value(b) - value(a) || byName(a, b));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clicking the sorted column flips it; a new column starts A to Z for names, highest first for counts
|
||||||
|
function sortBy(key) {
|
||||||
|
tableSort = tableSort.key === key
|
||||||
|
? { key, dir: tableSort.dir === 'asc' ? 'desc' : 'asc' }
|
||||||
|
: { key, dir: key === 'name' ? 'asc' : 'desc' };
|
||||||
|
try { localStorage.setItem('sdp-table-sort', JSON.stringify(tableSort)); } catch (err) {}
|
||||||
|
render();
|
||||||
|
// the table was redrawn; keep keyboard focus on the header just used
|
||||||
|
const btn = [...document.querySelectorAll('[data-sort-key]')].find(b => b.dataset.sortKey === key);
|
||||||
|
if (btn) btn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('main').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('[data-sort-key]');
|
||||||
|
if (btn) sortBy(btn.dataset.sortKey);
|
||||||
|
});
|
||||||
|
|
||||||
function renderWorkload(wl) {
|
function renderWorkload(wl) {
|
||||||
// hidden columns only leave the table; Total open still counts them
|
// hidden columns only leave the table; Total open still counts them
|
||||||
const hidden = new Set(data.settings.hiddenStatuses.map(n => n.toLowerCase()));
|
const hidden = new Set(data.settings.hiddenStatuses.map(n => n.toLowerCase()));
|
||||||
const statuses = (wl.statuses || []).filter(st => !hidden.has(st.name.toLowerCase()));
|
const statuses = (wl.statuses || []).filter(st => !hidden.has(st.name.toLowerCase()));
|
||||||
const sort = data.settings.tableSort || 'list';
|
const techs = sortTechs(wl.technicians || [], tableSort);
|
||||||
const techs = sortTechs(wl.technicians || [], sort);
|
// a header that sorts the table; the arrow points at the bigger end (↓ is A to Z or lowest first)
|
||||||
// marks the column the rows are sorted by
|
const sortHead = (key, label) => {
|
||||||
const sorted = key => key.toLowerCase() === sort.toLowerCase() ?' aria-sort="' + (key === 'name' ? 'ascending' : 'descending') + '"' : '';
|
const on = key === tableSort.key;
|
||||||
|
const asc = tableSort.dir === 'asc';
|
||||||
|
const next = on ? (asc ? 'descending' : 'ascending') : (key === 'name' ? 'ascending' : 'descending');
|
||||||
|
return `<button type="button" class="sort" data-sort-key="${esc(key)}" title="Sort ${next}">${label}`
|
||||||
|
+ `<span class="arrow" aria-hidden="true">${on ? (asc ? '↓' : '↑') : ''}</span></button>`;
|
||||||
|
};
|
||||||
|
const sorted = key => key === tableSort.key ? ` aria-sort="${tableSort.dir === 'asc' ? 'ascending' : 'descending'}"` : '';
|
||||||
|
|
||||||
const note = wl.updatedAt
|
const note = wl.updatedAt
|
||||||
? 'Updated ' + clockTime(wl.updatedAt) + (wl.truncated ? ' · partial: ' + wl.truncated : '')
|
? 'Updated ' + clockTime(wl.updatedAt) + (wl.truncated ? ' · partial: ' + wl.truncated : '')
|
||||||
@ -984,9 +1024,10 @@ function renderWorkload(wl) {
|
|||||||
: `<td class="num zero ${cls || ''}">·</td>`;
|
: `<td class="num zero ${cls || ''}">·</td>`;
|
||||||
|
|
||||||
// totals come right after the name so they stay visible when the status columns scroll off
|
// totals come right after the name so they stay visible when the status columns scroll off
|
||||||
const head = `<tr><th scope="col"${sorted('name')}>Technician</th>`
|
const head = `<tr><th scope="col"${sorted('name')}>${sortHead('name', 'Technician')}</th>`
|
||||||
+ `<th scope="col" class="total"${sorted('open')}>Total open</th><th scope="col" class="closed"${sorted('closedToday')}>Closed today</th>`
|
+ `<th scope="col" class="total"${sorted('open')}>${sortHead('open', 'Total open')}</th>`
|
||||||
+ statuses.map(s => `<th scope="col"${sorted('status:' + s.name)}${s.color ? ` style="--status-color:${s.color}"` : ''}><span class="swatch"></span>${esc(s.name)}</th>`).join('')
|
+ `<th scope="col" class="closed"${sorted('closedToday')}>${sortHead('closedToday', 'Closed today')}</th>`
|
||||||
|
+ statuses.map(s => `<th scope="col"${sorted('status:' + s.name)}${s.color ? ` style="--status-color:${s.color}"` : ''}>${sortHead('status:' + s.name, '<span class="swatch"></span>' + esc(s.name))}</th>`).join('')
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
|
|
||||||
const rows = techs.map(t => `<tr${flashTechs.has(t.name) ? ' class="flash"' : ''}>
|
const rows = techs.map(t => `<tr${flashTechs.has(t.name) ? ' class="flash"' : ''}>
|
||||||
@ -1208,19 +1249,6 @@ function renderSettings() {
|
|||||||
}
|
}
|
||||||
const feedStatuses = new Set(lower(d.feedStatuses));
|
const feedStatuses = new Set(lower(d.feedStatuses));
|
||||||
|
|
||||||
// a saved status sort stays on the list even if that status has no tickets right now
|
|
||||||
const sortStatuses = statusNames.slice();
|
|
||||||
if (d.tableSort.startsWith('status:') && !lower(sortStatuses).includes(d.tableSort.slice(7).toLowerCase())) {
|
|
||||||
sortStatuses.push(d.tableSort.slice(7));
|
|
||||||
}
|
|
||||||
const sortChoices = [
|
|
||||||
['list', d.technicians.length ? 'Technician list order' : 'Technician name (default)'],
|
|
||||||
['name', 'Technician name'],
|
|
||||||
['open', 'Total open'],
|
|
||||||
['closedToday', 'Closed today'],
|
|
||||||
...sortStatuses.map(n => ['status:' + n, n])
|
|
||||||
];
|
|
||||||
|
|
||||||
const body = !data.canSaveSettings
|
const body = !data.canSaveSettings
|
||||||
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
|
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
|
||||||
: `<div class="settings-body">
|
: `<div class="settings-body">
|
||||||
@ -1264,16 +1292,6 @@ function renderSettings() {
|
|||||||
<div class="checks">${feedChoices.map(n => `<label><input type="checkbox" data-feed-status="${esc(n)}"${feedStatuses.has(n.toLowerCase()) ? ' checked' : ''}>${esc(n)}</label>`).join('')}</div>
|
<div class="checks">${feedChoices.map(n => `<label><input type="checkbox" data-feed-status="${esc(n)}"${feedStatuses.has(n.toLowerCase()) ? ' checked' : ''}>${esc(n)}</label>`).join('')}</div>
|
||||||
<div class="hint">Tick Resolved to hear about tickets when they're resolved, not days later when they close on their own. They won't ding again when they close.</div>
|
<div class="hint">Tick Resolved to hear about tickets when they're resolved, not days later when they close on their own. They won't ding again when they close.</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
|
||||||
<legend>Table order</legend>
|
|
||||||
<div class="field">
|
|
||||||
<label for="set-sort">Sort rows by</label>
|
|
||||||
<select id="set-sort" data-sort>
|
|
||||||
${sortChoices.map(([value, label]) => `<option value="${esc(value)}"${value.toLowerCase() === d.tableSort.toLowerCase() ? ' selected' : ''}>${esc(label)}</option>`).join('')}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="hint">Counts sort busiest first, names A to Z.</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Status columns</legend>
|
<legend>Status columns</legend>
|
||||||
${statusNames.length
|
${statusNames.length
|
||||||
@ -1338,7 +1356,6 @@ $('settings').addEventListener('change', e => {
|
|||||||
const t = e.target;
|
const t = e.target;
|
||||||
if (t.dataset.num) draft[t.dataset.num] = Number(t.value);
|
if (t.dataset.num) draft[t.dataset.num] = Number(t.value);
|
||||||
if (t.dataset.zip !== undefined) draft.weatherZip = t.value;
|
if (t.dataset.zip !== undefined) draft.weatherZip = t.value;
|
||||||
if (t.dataset.sort !== undefined) draft.tableSort = t.value;
|
|
||||||
if (t.dataset.panel) {
|
if (t.dataset.panel) {
|
||||||
draft.hiddenPanels = draft.hiddenPanels.filter(p => p !== t.dataset.panel);
|
draft.hiddenPanels = draft.hiddenPanels.filter(p => p !== t.dataset.panel);
|
||||||
if (!t.checked) draft.hiddenPanels.push(t.dataset.panel);
|
if (!t.checked) draft.hiddenPanels.push(t.dataset.panel);
|
||||||
@ -1376,8 +1393,8 @@ async function saveSettings(btn, body) {
|
|||||||
const input = $('add-' + field);
|
const input = $('add-' + field);
|
||||||
if (input && input.value.trim()) addName(field, input);
|
if (input && input.value.trim()) addName(field, input);
|
||||||
}
|
}
|
||||||
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels, feedStatuses, tableSort } = draft;
|
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels, feedStatuses } = draft;
|
||||||
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels, feedStatuses, tableSort };
|
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels, feedStatuses };
|
||||||
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,6 @@ const defaults = {
|
|||||||
feedHours: 12, // how long a closed ticket stays in the feed
|
feedHours: 12, // how long a closed ticket stays in the feed
|
||||||
weatherZip: OWM_ZIP_CODE || null,
|
weatherZip: OWM_ZIP_CODE || null,
|
||||||
hiddenStatuses: [], // status columns left out of the table
|
hiddenStatuses: [], // status columns left out of the table
|
||||||
tableSort: 'list', // see TABLE_SORTS in settings.js
|
|
||||||
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
|
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
|
||||||
// which comes days before SDP closes a ticket on its own
|
// which comes days before SDP closes a ticket on its own
|
||||||
feedStatuses: closedStatuses,
|
feedStatuses: closedStatuses,
|
||||||
|
|||||||
@ -21,29 +21,19 @@ const PANELS = ['stats', 'weather', 'feed'];
|
|||||||
|
|
||||||
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses', 'feedStatuses'];
|
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses', 'feedStatuses'];
|
||||||
|
|
||||||
// how the technician table's rows can be ordered, besides 'status:<name>' for a status column.
|
|
||||||
// 'list' is the technician list as entered, or A to Z when watching everyone
|
|
||||||
const TABLE_SORTS = ['list', 'name', 'open', 'closedToday'];
|
|
||||||
|
|
||||||
class SettingsError extends Error {}
|
class SettingsError extends Error {}
|
||||||
|
|
||||||
// trims and collapses spaces; rejects anything that isn't a plain name
|
// trims, collapses spaces and drops case-insensitive repeats; rejects anything that isn't a plain name
|
||||||
function cleanName(item, field) {
|
|
||||||
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
|
|
||||||
const name = item.replace(/\s+/g, ' ').trim();
|
|
||||||
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
|
|
||||||
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleans each name and drops blanks and case-insensitive repeats
|
|
||||||
function cleanList(value, field) {
|
function cleanList(value, field) {
|
||||||
if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`);
|
if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
const name = cleanName(item, field);
|
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
|
||||||
|
const name = item.replace(/\s+/g, ' ').trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
|
||||||
|
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
|
||||||
if (seen.has(name.toLowerCase())) continue;
|
if (seen.has(name.toLowerCase())) continue;
|
||||||
seen.add(name.toLowerCase());
|
seen.add(name.toLowerCase());
|
||||||
out.push(name);
|
out.push(name);
|
||||||
@ -59,15 +49,6 @@ function cleanNumber(value, field) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanSort(value) {
|
|
||||||
if (TABLE_SORTS.includes(value)) return value;
|
|
||||||
if (typeof value === 'string' && value.startsWith('status:')) {
|
|
||||||
const name = cleanName(value.slice('status:'.length), 'tableSort');
|
|
||||||
if (name) return 'status:' + name;
|
|
||||||
}
|
|
||||||
throw new SettingsError(`tableSort must be one of ${TABLE_SORTS.join(', ')} or a status column`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they
|
// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they
|
||||||
@ -90,7 +71,6 @@ function createUserSettings({ dataDir, defaults, places }) {
|
|||||||
for (const field of ['workloadMinutes', 'feedHours']) {
|
for (const field of ['workloadMinutes', 'feedHours']) {
|
||||||
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
|
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
|
||||||
}
|
}
|
||||||
if (input.tableSort !== undefined) clean.tableSort = cleanSort(input.tableSort);
|
|
||||||
if (input.weatherZip !== undefined) {
|
if (input.weatherZip !== undefined) {
|
||||||
if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list');
|
if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list');
|
||||||
clean.weatherZip = input.weatherZip;
|
clean.weatherZip = input.weatherZip;
|
||||||
@ -161,7 +141,6 @@ function createUserSettings({ dataDir, defaults, places }) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
LIMITS,
|
LIMITS,
|
||||||
PANELS,
|
PANELS,
|
||||||
TABLE_SORTS,
|
|
||||||
SettingsError,
|
SettingsError,
|
||||||
createUserSettings
|
createUserSettings
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user