Projekte: Typ-, Hosting- und Aktionslogik als getestete Lib-Funktionen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bea91d9beb
commit
d2d4ad9cfb
2 files changed
+146
No files matched your search
@@ -0,0 +1,90 @@
|
|||||||
|
// Logik der Projektliste (Typ, Hosting-Zustand, Aktionen je Zeile) ohne Markup.
|
||||||
|
// Aus ProjectsPage herausgezogen; die Töne (info/ok/warn/muted) lösen die Badge-Komponenten auf.
|
||||||
|
import { formatDistance } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import { fmtDateTime } from './format'
|
||||||
|
|
||||||
|
// Projekt-Typen (= "Template"/Hosting-Modus). Die Reihenfolge der Schlüssel bestimmt die Optionen im Dialog.
|
||||||
|
export const PROJECT_TYPES = {
|
||||||
|
preview: {
|
||||||
|
label: 'Preview (mit Login)',
|
||||||
|
short: 'Preview',
|
||||||
|
tone: 'info',
|
||||||
|
hint: 'Login-geschuetzte Vorschau unter <subdomain>.project.webklar.com',
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
label: 'Oeffentliche Website',
|
||||||
|
short: 'Website',
|
||||||
|
tone: 'ok',
|
||||||
|
hint: 'Oeffentlich erreichbar ohne Login unter <subdomain>.project.webklar.com',
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
label: 'Vorlage',
|
||||||
|
short: 'Vorlage',
|
||||||
|
tone: 'warn',
|
||||||
|
hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht',
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
label: 'Internes Projekt (kein Hosting)',
|
||||||
|
short: 'Projekt',
|
||||||
|
tone: 'muted',
|
||||||
|
hint: 'Nur Gitea-Repository, keine Website/Subdomain',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typen mit Hosting (Subdomain + Deploy)
|
||||||
|
export const HOSTED_TYPES = new Set(['preview', 'website', 'template'])
|
||||||
|
|
||||||
|
export function typeOf(project) {
|
||||||
|
if (project?.isTemplate) return 'template'
|
||||||
|
if (project?.isPublic) return 'website'
|
||||||
|
if (project?.subdomain) return 'preview'
|
||||||
|
return 'project'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js):
|
||||||
|
// nur 'ready'/'deployed' gelten als tatsaechlich gehostet.
|
||||||
|
export function isProjectReady(project) {
|
||||||
|
const st = String(project?.provisioningStatus || project?.status || '').toLowerCase()
|
||||||
|
return ['ready', 'deployed'].includes(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Aktionen und welches Hosting-Kennzeichen eine Zeile zeigt.
|
||||||
|
* host = "Hosten" (noch nicht deployt), preview = Vorschau-Link, refresh = "Aktualisieren" (erneut deployen).
|
||||||
|
*/
|
||||||
|
export function projectActions(project) {
|
||||||
|
const type = typeOf(project)
|
||||||
|
const hosted = HOSTED_TYPES.has(type)
|
||||||
|
const ready = isProjectReady(project)
|
||||||
|
const archived = Boolean(project?.archived)
|
||||||
|
const host = hosted && !ready && !archived
|
||||||
|
const preview = !host && Boolean(project?.previewUrl)
|
||||||
|
const refresh = hosted && ready && !archived
|
||||||
|
const gitea = Boolean(project?.giteaRepoUrl)
|
||||||
|
const hostedBadge = !hosted || archived
|
||||||
|
? null
|
||||||
|
: ready
|
||||||
|
? { text: 'gehostet', tone: 'ok' }
|
||||||
|
: { text: 'nicht deployt', tone: 'warn' }
|
||||||
|
return { type, hosted, ready, host, preview, refresh, gitea, hostedBadge }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticketnummer (woid) der Zeile: aus der Ticketliste, sonst die $id, ohne Ticket leer. */
|
||||||
|
export function projectTicketRef(project, ticketMap) {
|
||||||
|
const id = project?.ticketId
|
||||||
|
if (!id) return ''
|
||||||
|
return String(ticketMap?.[id] ?? id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "vor etwa 3 Stunden" mit Titel in Berliner Zeit; fehlend oder unlesbar -> "-". */
|
||||||
|
export function updatedLabel(iso, now = new Date()) {
|
||||||
|
if (!iso) return { text: '-', title: '' }
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return { text: '-', title: '' }
|
||||||
|
return {
|
||||||
|
text: formatDistance(date, now, { addSuffix: true, locale: de }),
|
||||||
|
title: fmtDateTime(iso),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { PROJECT_TYPES, typeOf, isProjectReady, projectActions, projectTicketRef, updatedLabel } = await loadModule('src/lib/projectRow.js')
|
||||||
|
|
||||||
|
test('typeOf: Vorlage vor Website vor Preview vor Projekt', () => {
|
||||||
|
assert.equal(typeOf({ isTemplate: true, isPublic: true, subdomain: 'x' }), 'template')
|
||||||
|
assert.equal(typeOf({ isPublic: true, subdomain: 'x' }), 'website')
|
||||||
|
assert.equal(typeOf({ subdomain: 'x' }), 'preview')
|
||||||
|
assert.equal(typeOf({}), 'project')
|
||||||
|
})
|
||||||
|
test('isProjectReady: nur ready/deployed, provisioningStatus hat Vorrang', () => {
|
||||||
|
assert.equal(isProjectReady({ status: 'ready' }), true)
|
||||||
|
assert.equal(isProjectReady({ status: 'DEPLOYED' }), true)
|
||||||
|
assert.equal(isProjectReady({ provisioningStatus: 'pending', status: 'ready' }), false)
|
||||||
|
assert.equal(isProjectReady({ status: 'building' }), false)
|
||||||
|
assert.equal(isProjectReady({}), false)
|
||||||
|
})
|
||||||
|
test('PROJECT_TYPES: Reihenfolge, Texte und Töne unverändert', () => {
|
||||||
|
assert.deepEqual(Object.keys(PROJECT_TYPES), ['preview', 'website', 'template', 'project'])
|
||||||
|
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.label), ['Preview (mit Login)', 'Oeffentliche Website', 'Vorlage', 'Internes Projekt (kein Hosting)'])
|
||||||
|
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.short), ['Preview', 'Website', 'Vorlage', 'Projekt'])
|
||||||
|
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.tone), ['info', 'ok', 'warn', 'muted'])
|
||||||
|
assert.equal(PROJECT_TYPES.template.adminOnly, true)
|
||||||
|
assert.equal(PROJECT_TYPES.project.hint, 'Nur Gitea-Repository, keine Website/Subdomain')
|
||||||
|
})
|
||||||
|
test('projectActions: gehostet und nicht deployt', () => {
|
||||||
|
const ready = projectActions({ subdomain: 'a', status: 'ready', previewUrl: 'https://a.project.webklar.com', giteaRepoUrl: 'https://git/x' })
|
||||||
|
assert.deepEqual(ready, { type: 'preview', hosted: true, ready: true, host: false, preview: true, refresh: true, gitea: true, hostedBadge: { text: 'gehostet', tone: 'ok' } })
|
||||||
|
const open = projectActions({ subdomain: 'a', status: 'provisioning', previewUrl: 'https://a.project.webklar.com' })
|
||||||
|
assert.deepEqual(open, { type: 'preview', hosted: true, ready: false, host: true, preview: false, refresh: false, gitea: false, hostedBadge: { text: 'nicht deployt', tone: 'warn' } })
|
||||||
|
})
|
||||||
|
test('projectActions: archiviert, ohne Website, Vorlage', () => {
|
||||||
|
const arch = projectActions({ subdomain: 'a', status: 'ready', archived: true, previewUrl: 'u' })
|
||||||
|
assert.deepEqual([arch.host, arch.preview, arch.refresh, arch.hostedBadge], [false, true, false, null])
|
||||||
|
const archOpen = projectActions({ subdomain: 'a', status: 'error', archived: true })
|
||||||
|
assert.deepEqual([archOpen.host, archOpen.refresh, archOpen.hostedBadge], [false, false, null])
|
||||||
|
const intern = projectActions({ giteaRepoUrl: 'https://git/x' })
|
||||||
|
assert.deepEqual([intern.type, intern.host, intern.preview, intern.refresh, intern.hostedBadge, intern.gitea], ['project', false, false, false, null, true])
|
||||||
|
const tpl = projectActions({ isTemplate: true, subdomain: 't', status: 'deployed' })
|
||||||
|
assert.deepEqual([tpl.type, tpl.refresh, tpl.host], ['template', true, false])
|
||||||
|
})
|
||||||
|
test('projectTicketRef: woid aus der Liste, sonst die $id, ohne Ticket leer', () => {
|
||||||
|
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: '100131' }), '100131')
|
||||||
|
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: 100131 }), '100131')
|
||||||
|
assert.equal(projectTicketRef({ ticketId: 'zzz' }, {}), 'zzz')
|
||||||
|
assert.equal(projectTicketRef({ ticketId: '' }, { abc: '1' }), '')
|
||||||
|
assert.equal(projectTicketRef({}, {}), '')
|
||||||
|
})
|
||||||
|
test('updatedLabel: deutsch relativ, Titel in Berliner Zeit, ungültig = "-"', () => {
|
||||||
|
const now = new Date('2026-10-05T10:00:00.000Z')
|
||||||
|
assert.deepEqual(updatedLabel('2026-10-05T07:00:00.000Z', now), { text: 'vor etwa 3 Stunden', title: '05.10.26, 09:00' })
|
||||||
|
assert.equal(updatedLabel('2026-10-04T10:00:00.000Z', now).text, 'vor 1 Tag')
|
||||||
|
for (const bad of ['', 'kaputt', undefined]) assert.deepEqual(updatedLabel(bad, now), { text: '-', title: '' })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user