AI Implementation feature(887): Admin Area General Settings and Categories 1.05 (#27)

This commit was merged in pull request #27.
This commit is contained in:
2026-07-22 14:09:04 +00:00
parent 98fee8f7ee
commit c52a736ae1
10 changed files with 467 additions and 51 deletions
@@ -7,8 +7,10 @@ import { BootstrapService } from '../../core/services/bootstrap.service';
import { MarkdownService } from '../../core/services/markdown.service';
import { AdminCategoriesComponent } from './categories/categories.component';
import {
datetimeMessage,
deriveEventState,
endAfterStartValidator,
isoDatetimeValidator,
normalizePageTitle,
pageTitleMessage,
toDatetimeLocal,
@@ -91,14 +93,34 @@ import {
<label for="eventStartUtc">Event start (UTC)</label>
<div>
<input id="eventStartUtc" type="datetime-local" formControlName="eventStartUtc" data-testid="general-eventStart" />
<input
id="eventStartUtc"
type="datetime-local"
formControlName="eventStartUtc"
data-testid="general-eventStart"
[attr.aria-invalid]="showEventStartError() ? 'true' : null"
[attr.aria-describedby]="showEventStartError() ? 'general-eventStart-error' : null"
/>
@if (showEventStartError()) {
<div class="field-error" id="general-eventStart-error" data-testid="general-eventStart-error">{{ eventStartMessage() }}</div>
}
</div>
<label for="eventEndUtc">Event end (UTC)</label>
<div>
<input id="eventEndUtc" type="datetime-local" formControlName="eventEndUtc" data-testid="general-eventEnd" />
<input
id="eventEndUtc"
type="datetime-local"
formControlName="eventEndUtc"
data-testid="general-eventEnd"
[attr.aria-invalid]="showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart']) ? 'true' : null"
[attr.aria-describedby]="(showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart'])) ? 'general-eventEnd-error general-endBeforeStart' : null"
/>
@if (showEventEndError()) {
<div class="field-error" id="general-eventEnd-error" data-testid="general-eventEnd-error">{{ eventEndMessage() }}</div>
}
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) {
<div class="field-error" data-testid="general-endBeforeStart">Event end must be after event start.</div>
<div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">Event end must be after event start.</div>
}
</div>
@@ -179,14 +201,22 @@ export class AdminGeneralComponent implements OnInit {
private readonly pageTitleInvalid = signal<boolean>(false);
private readonly pageTitleTouchedOrDirty = signal<boolean>(false);
private readonly eventStartValue = signal<string>('');
private readonly eventStartInvalid = signal<boolean>(false);
private readonly eventStartTouchedOrDirty = signal<boolean>(false);
private readonly eventEndValue = signal<string>('');
private readonly eventEndInvalid = signal<boolean>(false);
private readonly eventEndTouchedOrDirty = signal<boolean>(false);
readonly form = this.fb.nonNullable.group(
{
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
logo: this.fb.nonNullable.control(''),
welcomeMarkdown: this.fb.nonNullable.control(''),
themeKey: this.fb.nonNullable.control('classic'),
eventStartUtc: this.fb.nonNullable.control(''),
eventEndUtc: this.fb.nonNullable.control(''),
eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]),
registrationsEnabled: this.fb.nonNullable.control(false),
},
@@ -210,6 +240,22 @@ export class AdminGeneralComponent implements OnInit {
pageTitleMessage(this.pageTitleValue(), this.form.controls.pageTitle.errors),
);
readonly showEventStartError = computed(
() => this.eventStartTouchedOrDirty() && this.eventStartInvalid(),
);
readonly eventStartMessage = computed(() =>
datetimeMessage('Event start', this.eventStartValue(), this.form.controls.eventStartUtc.errors),
);
readonly showEventEndError = computed(
() => this.eventEndTouchedOrDirty() && this.eventEndInvalid(),
);
readonly eventEndMessage = computed(() =>
datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors),
);
constructor() {
this.form.controls.welcomeMarkdown.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -231,6 +277,40 @@ export class AdminGeneralComponent implements OnInit {
this.pageTitleInvalid.set(pt.invalid);
this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty);
});
const es = this.form.controls.eventStartUtc;
this.eventStartValue.set(es.value);
this.eventStartInvalid.set(es.invalid);
this.eventStartTouchedOrDirty.set(es.touched || es.dirty);
es.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.eventStartValue.set(v);
this.eventStartTouchedOrDirty.set(es.touched || es.dirty);
});
es.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.eventStartInvalid.set(es.invalid);
this.eventStartTouchedOrDirty.set(es.touched || es.dirty);
});
const ee = this.form.controls.eventEndUtc;
this.eventEndValue.set(ee.value);
this.eventEndInvalid.set(ee.invalid);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
ee.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.eventEndValue.set(v);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
});
ee.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.eventEndInvalid.set(ee.invalid);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
});
}
async ngOnInit(): Promise<void> {
@@ -272,6 +352,14 @@ export class AdminGeneralComponent implements OnInit {
this.form.controls.pageTitle.markAsUntouched();
this.form.controls.pageTitle.markAsPristine();
this.pageTitleTouchedOrDirty.set(false);
this.form.controls.eventStartUtc.markAsUntouched();
this.form.controls.eventStartUtc.markAsPristine();
this.eventStartTouchedOrDirty.set(false);
this.form.controls.eventEndUtc.markAsUntouched();
this.form.controls.eventEndUtc.markAsPristine();
this.eventEndTouchedOrDirty.set(false);
}
async onSubmit(): Promise<void> {
@@ -64,3 +64,25 @@ export function toIsoUtc(local: string): string {
if (Number.isNaN(d.getTime())) return local;
return d.toISOString();
}
export function isoDatetimeValidator(ctrl: { value: string | null | undefined }): { invalidDatetime: true } | null {
const v = ctrl?.value ?? '';
if (v === '') return null;
const t = Date.parse(v);
return Number.isFinite(t) ? null : { invalidDatetime: true };
}
export function datetimeMessage(
fieldLabel: string,
value: string | null | undefined,
controlErrors: { [key: string]: unknown } | null | undefined,
): string | null {
if (controlErrors?.['invalidDatetime']) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
}
const v = value ?? '';
if (v !== '' && !Number.isFinite(Date.parse(v))) {
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
}
return null;
}