Tickets: ticketPath, Listenzustand in der URL (Reiter, Art, Dringend, Suche, limit), woid-Umleitung, Zeilenklick-Helfer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3b73ca1dd
commit
54fc44ed11
7 files changed
+249
-1
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Adressen der Ticketseite: /tickets/<woid oder $id> 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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user