diff --git a/src/lib/rowClick.js b/src/lib/rowClick.js new file mode 100644 index 0000000..9fc69af --- /dev/null +++ b/src/lib/rowClick.js @@ -0,0 +1,13 @@ +/* Klickbare Tabellenzeilen: nur ein einfacher Linksklick auf die Zeile selbst oeffnet sie. + Klicks auf Links, Knoepfe und Formularfelder in der Zeile, Mittelklick und Klicks mit + Modifikatortaste (neuer Tab, Auswahl) bleiben Sache des Browsers bzw. des Elements. */ + +const INTERACTIVE = 'a,button,select,input,textarea,label' + +export function isRowActivation(event) { + if (event.defaultPrevented || event.button !== 0) return false + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false + const hit = event.target?.closest?.(INTERACTIVE) + // Ein interaktiver Vorfahre ausserhalb der Zeile zaehlt nicht + return !(hit && event.currentTarget?.contains?.(hit)) +} diff --git a/src/lib/ticketListUrl.js b/src/lib/ticketListUrl.js new file mode 100644 index 0000000..6e3c21f --- /dev/null +++ b/src/lib/ticketListUrl.js @@ -0,0 +1,85 @@ +/* Zustand der Ticketliste in der Adresse: status (Reiter-Id), art (Id aus TYPE_FILTERS), + dringend (1), q, limit - in dieser Reihenfolge, Standardwerte werden nicht geschrieben. + Aus dem Zustand entstehen die Filter fuer Liste und Zaehler (beide muessen dieselben nutzen). */ + +import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from './ticketStatus' +import { ticketPath } from './ticketRoutes' + +// Reiter: "Aktiv" (alles Laufende), je eine Statusgruppe, zuletzt "Alle" (groups: null = kein Statusfilter) +export const LIST_TABS = [ + { id: 'aktiv', label: 'Aktiv', groups: DEFAULT_STATUS_GROUPS }, + ...STATUS_GROUPS.map((group) => ({ id: group.id, label: group.label, groups: [group.id] })), + { id: 'alle', label: 'Alle', groups: null }, +] + +export const LIST_DEFAULTS = { tab: 'aktiv', typeId: 'orders', urgentOnly: false, search: '', limit: 10 } + +const MIN_LIMIT = 10 +const MAX_LIMIT = 500 +const LIMIT_STEP = 10 +// Hoch (3) und Kritisch (4) gelten als dringend (siehe ticketStatus.js) +const URGENT_PRIORITIES = [3, 4] + +export function clampLimit(value) { + const n = Math.floor(Number(value)) + if (!Number.isFinite(n)) return MIN_LIMIT + return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, n)) +} + +// input: URLSearchParams oder String ("?status=done" und "status=done" gehen beide) +export function parseListParams(input) { + const params = new URLSearchParams(input) + const tab = params.get('status') + const typeId = params.get('art') + const limit = params.get('limit') + return { + tab: LIST_TABS.some((t) => t.id === tab) ? tab : LIST_DEFAULTS.tab, + typeId: TYPE_FILTERS.some((f) => f.id === typeId) ? typeId : LIST_DEFAULTS.typeId, + urgentOnly: params.get('dringend') === '1', + search: (params.get('q') || '').trim(), + limit: limit !== null && /^\d+$/.test(limit) ? clampLimit(limit) : LIST_DEFAULTS.limit, + } +} + +export function serializeListParams(state) { + const params = new URLSearchParams() + if (state.tab !== LIST_DEFAULTS.tab) params.set('status', state.tab) + if (state.typeId !== LIST_DEFAULTS.typeId) params.set('art', state.typeId) + if (state.urgentOnly) params.set('dringend', '1') + if (state.search) params.set('q', state.search) + const limit = clampLimit(state.limit) + if (limit !== LIST_DEFAULTS.limit) params.set('limit', String(limit)) + return params +} + +// Jede Filteraenderung beginnt wieder bei der ersten Seite +export function withFilter(state, patch) { + return { ...state, ...patch, limit: LIST_DEFAULTS.limit } +} + +// "Mehr laden" +export function nextLimit(state) { + return clampLimit(Number(state.limit) + LIMIT_STEP) +} + +// Filter fuer den Zaehler eines Reiters: wie die Liste, aber ohne Suche und limit +export function countFilters(state, tabId) { + const tab = LIST_TABS.find((t) => t.id === tabId) || LIST_TABS[0] + const type = TYPE_FILTERS.find((f) => f.id === state.typeId) || TYPE_FILTERS[0] + return { + status: tab.groups === null ? [] : statusValuesFor(tab.groups), + type: [...(type.types || [])], + excludeType: type.excludeType, + priority: state.urgentOnly ? [...URGENT_PRIORITIES] : [], + } +} + +export function listFilters(state) { + return { ...countFilters(state, state.tab), search: state.search || undefined, limit: state.limit } +} + +// Altlinks "/tickets?woid=100131" fuehren zur Ticketseite und haben Vorrang vor allen Filtern +export function woidRedirectTarget(input) { + const woid = (new URLSearchParams(input).get('woid') || '').trim() + return woid ? ticketPath(woid) : null +} diff --git a/src/lib/ticketRoutes.js b/src/lib/ticketRoutes.js new file mode 100644 index 0000000..87647d9 --- /dev/null +++ b/src/lib/ticketRoutes.js @@ -0,0 +1,28 @@ +/* Adressen der Ticketseite: /tickets/ mit dem Reiter Details (ohne Zusatz) + oder Arbeitsblaetter. Alle Links auf Tickets entstehen hier. */ + +// Reiter der Ticketseite; "details" hat keinen eigenen Pfadteil +export const TICKET_TABS = ['details', 'arbeitsblaetter'] + +// Das Ticket wird ueber seine woid adressiert, sonst ueber die $id (nie ueber die Kurznummer) +export function ticketRef(ticket) { + return String(ticket?.woid || ticket?.$id || '') +} + +export function ticketPath(ref, tab) { + const id = String(ref ?? '').trim() + if (!id) return '/tickets' + return '/tickets/' + encodeURIComponent(id) + (tab === 'arbeitsblaetter' ? '/arbeitsblaetter' : '') +} + +// Reiter aus der Adresse: fehlt er, gilt "details", ein unbekannter Wert ergibt null +export function resolveTicketTab(tab) { + if (tab === undefined) return 'details' + return TICKET_TABS.includes(tab) ? tab : null +} + +// Zurueck zur Liste mit dem gemerkten Suchteil (nur "?..." mit Inhalt, sonst kein Fremdtext in der Adresse) +export function listBackPath(locationState) { + const search = locationState?.listSearch + return typeof search === 'string' && search.startsWith('?') && search.length > 1 ? '/tickets' + search : '/tickets' +} diff --git a/src/lib/ticketStatus.js b/src/lib/ticketStatus.js index d6eccbf..274411f 100644 --- a/src/lib/ticketStatus.js +++ b/src/lib/ticketStatus.js @@ -106,7 +106,7 @@ export const TYPE_FILTERS = [ { id: 'Migration', label: 'Migration', types: ['Migration'] }, { id: 'Project', label: 'Projekt', types: ['Project'] }, { id: 'Akquise', label: 'Akquise', types: ['Akquise'] }, - { id: 'all', label: 'Alle' }, + { id: 'all', label: 'Aufträge und Akquise' }, ] // Suche in Kunde, Betreff, "Angefragt von" und WOID, ohne Gross-/Kleinschreibung diff --git a/tests/unit/rowClick.test.mjs b/tests/unit/rowClick.test.mjs new file mode 100644 index 0000000..a1deeb1 --- /dev/null +++ b/tests/unit/rowClick.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { isRowActivation } = await loadModule('src/lib/rowClick.js') + +const TAGS = ['a', 'button', 'select', 'input', 'textarea', 'label'] + +// Ereignis ohne DOM: closest liefert nur dann einen Treffer, wenn der Selektor das Tag nennt +function click({ target = 'td', outside = false, ...over } = {}) { + const row = { contains: (el) => el?.inRow === true } + const hit = TAGS.includes(target) ? { inRow: !outside } : null + return { + target: { closest: (sel) => (hit && sel.split(',').map((s) => s.trim()).includes(target) ? hit : null) }, + currentTarget: row, button: 0, defaultPrevented: false, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...over, + } +} + +test('Linksklick auf Zelle oder Zeile aktiviert die Zeile', () => { + assert.equal(isRowActivation(click()), true) + assert.equal(isRowActivation(click({ target: 'tr' })), true) +}) + +test('Klick auf a, button, select, input, textarea oder label aktiviert nicht', () => { + for (const tag of TAGS) assert.equal(isRowActivation(click({ target: tag })), false, tag) +}) + +test('Mittelklick, Modifikatortasten und behandelte Klicks aktivieren nicht', () => { + assert.equal(isRowActivation(click({ button: 1 })), false) + for (const key of ['metaKey', 'ctrlKey', 'shiftKey', 'altKey']) assert.equal(isRowActivation(click({ [key]: true })), false, key) + assert.equal(isRowActivation(click({ defaultPrevented: true })), false) +}) + +test('ein interaktiver Vorfahre außerhalb der Zeile zählt nicht', () => { + assert.equal(isRowActivation(click({ target: 'label', outside: true })), true) +}) diff --git a/tests/unit/ticketListUrl.test.mjs b/tests/unit/ticketListUrl.test.mjs new file mode 100644 index 0000000..204e65c --- /dev/null +++ b/tests/unit/ticketListUrl.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const m = await loadModule('src/lib/ticketListUrl.js') +const { LIST_TABS, LIST_DEFAULTS, parseListParams: parse, serializeListParams: ser, withFilter, nextLimit, listFilters, countFilters, woidRedirectTarget } = m +const { TYPE_FILTERS } = await loadModule('src/lib/ticketStatus.js') + +test('Reiter und Art-Auswahl nach Spec', () => { + assert.deepEqual(LIST_TABS.map((t) => [t.id, t.label]), [['aktiv', 'Aktiv'], ['open', 'Offen'], ['working', 'In Arbeit'], ['waiting', 'Wartet auf Kunde'], ['review', 'Prüfung'], ['done', 'Erledigt'], ['cancelled', 'Storniert'], ['alle', 'Alle']]) + assert.deepEqual(TYPE_FILTERS.map((f) => f.label), ['Aufträge', 'Webpage', 'Migration', 'Projekt', 'Akquise', 'Aufträge und Akquise']) +}) + +test('parse: Standard, gültige und ungültige Werte', () => { + assert.deepEqual(parse(''), LIST_DEFAULTS) + assert.deepEqual(parse('?status=done&art=all&dringend=1&q=%20Müller%20&limit=30'), { tab: 'done', typeId: 'all', urgentOnly: true, search: 'Müller', limit: 30 }) + assert.deepEqual(parse('status=foo&art=bar&dringend=ja&limit=abc'), LIST_DEFAULTS) + for (const [raw, want] of [['5', 10], ['0', 10], ['25', 25], ['500', 500], ['9999', 500], ['-3', 10], ['1e3', 10], ['', 10]]) assert.equal(parse('limit=' + raw).limit, want, raw) +}) + +test('serialize: Standardwerte werden nicht geschrieben, Rundlauf', () => { + assert.equal(ser(LIST_DEFAULTS).toString(), '') + assert.equal(ser({ ...LIST_DEFAULTS, tab: 'alle', limit: 20 }).toString(), 'status=alle&limit=20') + assert.equal(ser({ ...LIST_DEFAULTS, urgentOnly: true, search: 'a b' }).toString(), 'dringend=1&q=a+b') + for (const s of [LIST_DEFAULTS, { tab: 'done', typeId: 'Akquise', urgentOnly: true, search: 'x y', limit: 40 }]) assert.deepEqual(parse(ser(s)), s) +}) + +test('Filteränderung setzt limit zurück, Mehr laden +10 bis 500', () => { + for (const patch of [{ tab: 'done' }, { typeId: 'all' }, { urgentOnly: true }, { search: 'x' }]) assert.equal(withFilter({ ...LIST_DEFAULTS, limit: 50 }, patch).limit, 10) + assert.deepEqual([10, 495, 500].map((limit) => nextLimit({ limit })), [20, 500, 500]) +}) + +test('listFilters und countFilters', () => { + assert.deepEqual(listFilters(LIST_DEFAULTS), { status: ['Open', 'Occupied', 'Assigned', 'Added Info', 'In Kontakt', 'Awaiting', 'Halted', 'In Test'], type: [], excludeType: 'Akquise', priority: [], search: undefined, limit: 10 }) + assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'alle', typeId: 'Akquise', urgentOnly: true, search: 'abc' }), { status: [], type: ['Akquise'], excludeType: undefined, priority: [3, 4], search: 'abc', limit: 10 }) + assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'done' }).status, ['Closed', 'Zugesagt']) + const s = { ...LIST_DEFAULTS, typeId: 'Webpage', urgentOnly: true, search: 'zzz', limit: 80 } + assert.deepEqual(countFilters(s, 'review'), { status: ['In Test'], type: ['Webpage'], excludeType: undefined, priority: [3, 4] }) + for (const t of LIST_TABS) assert.deepEqual(countFilters(s, t.id).status, listFilters({ ...s, tab: t.id }).status, t.id) +}) + +test('woid-Umleitung hat Vorrang', () => { + assert.equal(woidRedirectTarget('?woid=100131'), '/tickets/100131') + assert.equal(woidRedirectTarget('?woid=%20100131%20&status=done&q=x'), '/tickets/100131') + assert.equal(woidRedirectTarget('?woid=a%2Fb'), '/tickets/a%2Fb') + for (const none of ['', '?woid=', '?status=done']) assert.equal(woidRedirectTarget(none), null) +}) diff --git a/tests/unit/ticketRoutes.test.mjs b/tests/unit/ticketRoutes.test.mjs new file mode 100644 index 0000000..bfa709b --- /dev/null +++ b/tests/unit/ticketRoutes.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { ticketPath, ticketRef, resolveTicketTab, listBackPath } = await loadModule('src/lib/ticketRoutes.js') + +test('ticketPath: woid, Zahl, $id, Reiter', () => { + assert.equal(ticketPath('100131'), '/tickets/100131') + assert.equal(ticketPath(100131), '/tickets/100131') + assert.equal(ticketPath('100131', 'details'), '/tickets/100131') + assert.equal(ticketPath('100131', 'arbeitsblaetter'), '/tickets/100131/arbeitsblaetter') + assert.equal(ticketPath('100131', 'unbekannt'), '/tickets/100131') + assert.equal(ticketPath('66f1c2a9000b1c2d3e4f'), '/tickets/66f1c2a9000b1c2d3e4f') +}) + +test('ticketPath: Sonderzeichen kodiert, leeres ref ergibt /tickets', () => { + assert.equal(ticketPath('a/b c?d#e'), '/tickets/a%2Fb%20c%3Fd%23e') + for (const empty of ['', ' ', null, undefined]) { + assert.equal(ticketPath(empty), '/tickets') + assert.equal(ticketPath(empty, 'arbeitsblaetter'), '/tickets') + } +}) + +test('ticketRef: woid vor $id, nie die Kurznummer', () => { + assert.equal(ticketRef({ woid: '100131', $id: 'abc' }), '100131') + assert.equal(ticketRef({ woid: '', $id: 'abcdefghij' }), 'abcdefghij') + assert.equal(ticketRef(null), '') +}) + +test('resolveTicketTab: unbekannt ergibt null', () => { + assert.equal(resolveTicketTab(undefined), 'details') + assert.equal(resolveTicketTab('arbeitsblaetter'), 'arbeitsblaetter') + for (const bad of ['Details', 'worksheets', '', 'x']) assert.equal(resolveTicketTab(bad), null) +}) + +test('listBackPath: nur Suchteile mit ?', () => { + assert.equal(listBackPath({ listSearch: '?status=done&q=x' }), '/tickets?status=done&q=x') + for (const bad of [undefined, null, {}, { listSearch: '' }, { listSearch: '?' }, { listSearch: 'evil' }, { listSearch: 42 }]) assert.equal(listBackPath(bad), '/tickets') +})