vaidikgarg-bit commented on code in PR #838: URL: https://github.com/apache/fineract-backoffice-ui/pull/838#discussion_r4387748073
########## src/app/features/system/scheduler-jobs/scheduler-job-form.component.ts: ########## @@ -0,0 +1,373 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, DestroyRef, NgZone, OnInit, computed, inject, signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { I18N, SCHEDULER_JOB_API, TranslatePipe } from '../../../core/adapters'; +import { NotificationService } from '../../../core/services/notification.service'; +import { + type CronProblem, + cronDateTimeOf, + formatCronDateTime, + nextFireTimes, + parseQuartzCron, +} from '../../../core/utils/quartz-cron'; +import { LoadErrorComponent } from '../../../shared/components/load-error/load-error.component'; +import { ButtonComponent } from '../../../ui/button/button.component'; +import { SpinnerComponent } from '../../../ui/spinner/spinner.component'; + +/** How many upcoming runs the preview lists. */ +const PREVIEW_COUNT = 3; +/** How often the preview re-reads the clock. */ +const PREVIEW_REFRESH_MS = 30_000; + +/** What the cron field currently says about itself. */ +type CronCheck = + | { readonly state: 'empty' } + | { readonly state: 'invalid'; readonly problem: CronProblem | { readonly kind: 'neverFires' } } + | { readonly state: 'valid'; readonly nextRuns: readonly string[] }; + +/** + * Edits one scheduler job: its display name, its Quartz cron expression and whether it is active. + * + * The expression is checked as it is typed, with the same rules Quartz applies on the server, + * and a valid one previews its next runs. That preview is the point of the screen: a job like + * interest posting or close-of-business that silently stops running is found days later, in the + * accounts, and the next fire times are how an administrator sees that before saving. + */ +@Component({ + selector: 'app-scheduler-job-form', + standalone: true, + imports: [FormsModule, TranslatePipe, ButtonComponent, SpinnerComponent, LoadErrorComponent], + template: ` + <div class="form-container"> + <section class="entity-card"> + <h2>{{ 'SCHEDULER_JOBS.EDIT_JOB' | appTranslate }}</h2> + + @if (isLoading()) { + <app-spinner data-testid="scheduler-job-loading" /> + } @else if (loadFailed()) { + <app-load-error + testId="scheduler-job-load-error" + [message]="'SCHEDULER_JOBS.LOAD_FAILED' | appTranslate" + [actionLabel]="(canRetry() ? 'COMMON.RETRY' : 'COMMON.BACK') | appTranslate" + (action)="canRetry() ? load() : onCancel()" + /> + } @else { + @if (currentlyRunning()) { + <p class="notice" role="status" data-testid="scheduler-job-running"> + {{ 'SCHEDULER_JOBS.CURRENTLY_RUNNING' | appTranslate }} + </p> + } + <form #jobForm="ngForm" (ngSubmit)="onSubmit()"> + <fieldset [disabled]="currentlyRunning()"> + <div class="form-grid"> + <div class="form-field full-width"> + <label for="displayName">{{ + 'SCHEDULER_JOBS.DISPLAY_NAME' | appTranslate + }}</label> + <input + id="displayName" + type="text" + name="displayName" + data-testid="scheduler-job-display-name" + required + [ngModel]="displayName()" + (ngModelChange)="displayName.set($event)" + /> + </div> + + <div class="form-field full-width"> + <label for="cronExpression">{{ + 'SCHEDULER_JOBS.CRON_EXPRESSION' | appTranslate + }}</label> + <input + id="cronExpression" + type="text" + name="cronExpression" + data-testid="scheduler-job-cron" + required + spellcheck="false" + autocomplete="off" + aria-describedby="cronExpression-hint cronExpression-check" + [attr.aria-invalid]="cronCheck().state === 'invalid'" + [ngModel]="cronExpression()" + (ngModelChange)="cronExpression.set($event)" + /> + <span id="cronExpression-hint" class="hint">{{ + 'SCHEDULER_JOBS.CRON_HINT' | appTranslate + }}</span> + <!-- Only the error is announced: the run list changes on every keystroke. --> + <div id="cronExpression-check"> + <span class="error" aria-live="polite" data-testid="scheduler-job-cron-error">{{ + cronError() + }}</span> + @switch (cronCheck().state) { + @case ('valid') { + <span class="hint">{{ 'SCHEDULER_JOBS.NEXT_RUNS' | appTranslate }}</span> + <ul class="next-runs" data-testid="scheduler-job-next-runs"> + @for (run of nextRuns(); track run) { + <li>{{ run }}</li> + } + </ul> + } + } + </div> + </div> + + <div class="form-field full-width"> + <label class="checkbox"> + <input + type="checkbox" + name="active" + data-testid="scheduler-job-active" + [ngModel]="active()" + (ngModelChange)="active.set($event)" + /> + {{ 'SCHEDULER_JOBS.ACTIVE' | appTranslate }} + </label> + </div> + </div> + + <p class="warning">{{ 'SCHEDULER_JOBS.SCHEDULE_WARNING' | appTranslate }}</p> + </fieldset> + + <div class="form-actions"> + <app-button + type="button" + intent="secondary" + emphasis="quiet" + [disabled]="isSaving()" + (click)="onCancel()" + >{{ 'COMMON.CANCEL' | appTranslate }}</app-button + > + <app-button + type="submit" + intent="primary" + data-testid="scheduler-job-save" + [disabled]="!canSave() || !!jobForm.invalid" + >{{ (isSaving() ? 'COMMON.SAVING' : 'COMMON.SAVE') | appTranslate }}</app-button + > + </div> + </form> + } + </section> + </div> + `, + styles: [ + ` + .entity-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + } + h2 { + margin: 0 0 16px; + font-size: 1.1rem; + } + fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + } + .hint { + font-size: 0.75rem; + color: var(--text-muted); + } + .error { + font-size: 0.75rem; + color: var(--error-color); + } + .next-runs { + margin: 4px 0 0; + padding-inline-start: 20px; + font-family: monospace; + font-size: 0.85rem; + } + .form-field label.checkbox { + flex-direction: row; + font-size: 0.9rem; + color: var(--text-color); + } + .form-field label.checkbox input { + width: auto; + } + .notice, + .warning { + font-size: 0.85rem; + color: var(--text-muted); + } + `, + ], +}) +export class SchedulerJobFormComponent implements OnInit { + private readonly jobApi = inject(SCHEDULER_JOB_API); + private readonly notifications = inject(NotificationService); + private readonly i18n = inject(I18N); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + private readonly LIST_PATH = '/system/scheduler-jobs'; + + private jobId?: number; + /** False when no retry can load the job (a malformed id, or a 404); the action leaves instead. */ + readonly canRetry = signal(true); + + readonly displayName = signal(''); + readonly cronExpression = signal(''); + readonly active = signal(false); + readonly currentlyRunning = signal(false); + + readonly isLoading = signal(false); + readonly loadFailed = signal(false); + readonly isSaving = signal(false); + + /** The clock the preview counts from, re-read so a form left open does not list past runs. */ + private readonly now = signal(new Date()); + + readonly cronCheck = computed<CronCheck>(() => { + const expression = this.cronExpression().trim(); + if (!expression) return { state: 'empty' }; + const parsed = parseQuartzCron(expression); + if (!parsed.ok) return { state: 'invalid', problem: parsed.problem }; + // Fineract fires in the tenant's time zone, which the screen does not know; the browser's + // clock is the nearest "now" it has, so the first run listed can be off by the zone gap. + const runs = nextFireTimes(parsed.schedule, cronDateTimeOf(this.now()), PREVIEW_COUNT); + if (runs.length === 0) return { state: 'invalid', problem: { kind: 'neverFires' } }; + return { state: 'valid', nextRuns: runs.map(formatCronDateTime) }; + }); + + readonly nextRuns = computed(() => { + const check = this.cronCheck(); + return check.state === 'valid' ? check.nextRuns : []; + }); + + readonly cronError = computed(() => { + const check = this.cronCheck(); + if (check.state !== 'invalid') return ''; + const problem = check.problem; + switch (problem.kind) { + case 'fieldCount': + return this.i18n.translate('SCHEDULER_JOBS.CRON_FIELD_COUNT'); + case 'dayConflict': + return this.i18n.translate('SCHEDULER_JOBS.CRON_DAY_CONFLICT'); + case 'neverFires': + return this.i18n.translate('SCHEDULER_JOBS.CRON_NEVER_FIRES'); + case 'field': + return this.i18n.translate('SCHEDULER_JOBS.CRON_INVALID_FIELD', { + field: this.i18n.translate(CRON_FIELD_LABELS[problem.field]), + value: problem.value, + }); + } + }); + + readonly canSave = computed( + () => + !this.isSaving() && + !this.currentlyRunning() && + this.displayName().trim() !== '' && + this.cronCheck().state === 'valid', Review Comment: Changed: Save now sends only the fields that differ from the loaded job, and stays disabled when nothing changed. The cron has to be valid only if it was edited, so a job whose stored cron the parser refuses can still be deactivated. A unit test covers that case and checks the PUT body is `{ active: false }`. ########## src/app/features/system/scheduler-jobs/scheduler-job-form.component.ts: ########## @@ -0,0 +1,373 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, DestroyRef, NgZone, OnInit, computed, inject, signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { I18N, SCHEDULER_JOB_API, TranslatePipe } from '../../../core/adapters'; +import { NotificationService } from '../../../core/services/notification.service'; +import { + type CronProblem, + cronDateTimeOf, + formatCronDateTime, + nextFireTimes, + parseQuartzCron, +} from '../../../core/utils/quartz-cron'; +import { LoadErrorComponent } from '../../../shared/components/load-error/load-error.component'; +import { ButtonComponent } from '../../../ui/button/button.component'; +import { SpinnerComponent } from '../../../ui/spinner/spinner.component'; + +/** How many upcoming runs the preview lists. */ +const PREVIEW_COUNT = 3; +/** How often the preview re-reads the clock. */ +const PREVIEW_REFRESH_MS = 30_000; + +/** What the cron field currently says about itself. */ +type CronCheck = + | { readonly state: 'empty' } + | { readonly state: 'invalid'; readonly problem: CronProblem | { readonly kind: 'neverFires' } } + | { readonly state: 'valid'; readonly nextRuns: readonly string[] }; + +/** + * Edits one scheduler job: its display name, its Quartz cron expression and whether it is active. + * + * The expression is checked as it is typed, with the same rules Quartz applies on the server, + * and a valid one previews its next runs. That preview is the point of the screen: a job like + * interest posting or close-of-business that silently stops running is found days later, in the + * accounts, and the next fire times are how an administrator sees that before saving. + */ +@Component({ + selector: 'app-scheduler-job-form', + standalone: true, + imports: [FormsModule, TranslatePipe, ButtonComponent, SpinnerComponent, LoadErrorComponent], + template: ` + <div class="form-container"> + <section class="entity-card"> + <h2>{{ 'SCHEDULER_JOBS.EDIT_JOB' | appTranslate }}</h2> + + @if (isLoading()) { + <app-spinner data-testid="scheduler-job-loading" /> + } @else if (loadFailed()) { + <app-load-error + testId="scheduler-job-load-error" + [message]="'SCHEDULER_JOBS.LOAD_FAILED' | appTranslate" + [actionLabel]="(canRetry() ? 'COMMON.RETRY' : 'COMMON.BACK') | appTranslate" + (action)="canRetry() ? load() : onCancel()" + /> + } @else { + @if (currentlyRunning()) { + <p class="notice" role="status" data-testid="scheduler-job-running"> + {{ 'SCHEDULER_JOBS.CURRENTLY_RUNNING' | appTranslate }} + </p> + } + <form #jobForm="ngForm" (ngSubmit)="onSubmit()"> + <fieldset [disabled]="currentlyRunning()"> + <div class="form-grid"> + <div class="form-field full-width"> + <label for="displayName">{{ + 'SCHEDULER_JOBS.DISPLAY_NAME' | appTranslate + }}</label> + <input + id="displayName" + type="text" + name="displayName" + data-testid="scheduler-job-display-name" + required + [ngModel]="displayName()" + (ngModelChange)="displayName.set($event)" + /> + </div> + + <div class="form-field full-width"> + <label for="cronExpression">{{ + 'SCHEDULER_JOBS.CRON_EXPRESSION' | appTranslate + }}</label> + <input + id="cronExpression" + type="text" + name="cronExpression" + data-testid="scheduler-job-cron" + required + spellcheck="false" + autocomplete="off" + aria-describedby="cronExpression-hint cronExpression-check" + [attr.aria-invalid]="cronCheck().state === 'invalid'" + [ngModel]="cronExpression()" + (ngModelChange)="cronExpression.set($event)" + /> + <span id="cronExpression-hint" class="hint">{{ + 'SCHEDULER_JOBS.CRON_HINT' | appTranslate + }}</span> + <!-- Only the error is announced: the run list changes on every keystroke. --> + <div id="cronExpression-check"> + <span class="error" aria-live="polite" data-testid="scheduler-job-cron-error">{{ + cronError() + }}</span> + @switch (cronCheck().state) { + @case ('valid') { + <span class="hint">{{ 'SCHEDULER_JOBS.NEXT_RUNS' | appTranslate }}</span> + <ul class="next-runs" data-testid="scheduler-job-next-runs"> + @for (run of nextRuns(); track run) { + <li>{{ run }}</li> + } + </ul> + } + } + </div> + </div> + + <div class="form-field full-width"> + <label class="checkbox"> + <input + type="checkbox" + name="active" + data-testid="scheduler-job-active" + [ngModel]="active()" + (ngModelChange)="active.set($event)" + /> + {{ 'SCHEDULER_JOBS.ACTIVE' | appTranslate }} + </label> + </div> + </div> + + <p class="warning">{{ 'SCHEDULER_JOBS.SCHEDULE_WARNING' | appTranslate }}</p> + </fieldset> + + <div class="form-actions"> + <app-button + type="button" + intent="secondary" + emphasis="quiet" + [disabled]="isSaving()" + (click)="onCancel()" + >{{ 'COMMON.CANCEL' | appTranslate }}</app-button + > + <app-button + type="submit" + intent="primary" + data-testid="scheduler-job-save" + [disabled]="!canSave() || !!jobForm.invalid" + >{{ (isSaving() ? 'COMMON.SAVING' : 'COMMON.SAVE') | appTranslate }}</app-button + > + </div> + </form> + } + </section> + </div> + `, + styles: [ + ` + .entity-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + } + h2 { + margin: 0 0 16px; + font-size: 1.1rem; + } + fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + } + .hint { + font-size: 0.75rem; + color: var(--text-muted); + } + .error { + font-size: 0.75rem; + color: var(--error-color); + } + .next-runs { + margin: 4px 0 0; + padding-inline-start: 20px; + font-family: monospace; + font-size: 0.85rem; + } + .form-field label.checkbox { + flex-direction: row; + font-size: 0.9rem; + color: var(--text-color); + } + .form-field label.checkbox input { + width: auto; + } + .notice, + .warning { + font-size: 0.85rem; + color: var(--text-muted); + } + `, + ], +}) +export class SchedulerJobFormComponent implements OnInit { + private readonly jobApi = inject(SCHEDULER_JOB_API); + private readonly notifications = inject(NotificationService); + private readonly i18n = inject(I18N); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + private readonly LIST_PATH = '/system/scheduler-jobs'; + + private jobId?: number; + /** False when no retry can load the job (a malformed id, or a 404); the action leaves instead. */ + readonly canRetry = signal(true); + + readonly displayName = signal(''); + readonly cronExpression = signal(''); + readonly active = signal(false); + readonly currentlyRunning = signal(false); + + readonly isLoading = signal(false); + readonly loadFailed = signal(false); + readonly isSaving = signal(false); + + /** The clock the preview counts from, re-read so a form left open does not list past runs. */ + private readonly now = signal(new Date()); + + readonly cronCheck = computed<CronCheck>(() => { + const expression = this.cronExpression().trim(); + if (!expression) return { state: 'empty' }; + const parsed = parseQuartzCron(expression); + if (!parsed.ok) return { state: 'invalid', problem: parsed.problem }; + // Fineract fires in the tenant's time zone, which the screen does not know; the browser's + // clock is the nearest "now" it has, so the first run listed can be off by the zone gap. + const runs = nextFireTimes(parsed.schedule, cronDateTimeOf(this.now()), PREVIEW_COUNT); Review Comment: Changed: the preview counts from `PlatformDateService.serverNow()` (the browser clock corrected by the skew from the last `Date` header) in `tenantTimezone`, and the label names the zone, e.g. "Next runs (tenant time, Asia/Kolkata)". One caveat: `DEFAULT_CONFIG.tenantTimezone` is `Asia/Kolkata`, so a deployment that never set its zone gets that label even when it is wrong. The browser-time label only shows when the zone is explicitly empty. ########## e2e/scheduler-jobs.spec.ts: ########## @@ -154,3 +155,63 @@ test.describe('Scheduler Jobs bulk run', () => { expect(ran).toBe(false); }); }); + +test.describe('Scheduler Jobs edit', () => { + test("changes a job's schedule and active flag, refusing an invalid expression first", async ({ + page, + }) => { + await mockSchedulerJobs(page); + const job = { + jobId: 1, + displayName: 'Update Loan Summary', + cronExpression: '0 0 0 1/1 * ? *', + active: true, + currentlyRunning: false, + }; + let sent: unknown; + await page.route(/\/api\/v1\/jobs\/1(\?|$)/, async (route) => { + if (route.request().method() === 'PUT') { + sent = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ resourceId: 1, changes: {} }), Review Comment: Added `e2e/scheduler-job-edit-backend.spec.ts` to `BACKEND_SPECS`. It moves the first idle job to a single run at 12:00 on 15 June 2099 and checks the list shows 2099. It also PUTs `0 0 * * * *` directly and expects a 400 with `validation.msg.jobs.cronExpression.invalid`, then restores the original cron and active flag in `afterAll` and asserts the restore succeeded. The mocked spec has a 400-on-save case too. Neither has run locally, so this push is their first CI run. ########## src/app/features/system/system.routes.ts: ########## @@ -437,6 +437,16 @@ export const SYSTEM_ROUTES: Routes = [ (m) => m.SchedulerJobsListComponent, ), }, + { + path: 'scheduler-jobs/:id/edit', + canActivate: [authGuard, permissionGuard], + data: { permissions: 'UPDATE_SCHEDULER' }, Review Comment: Changed: the route now requires `['READ_SCHEDULER', 'UPDATE_SCHEDULER']` with `permissionsMatchAll: true`, and a 403 on load offers Back instead of Try again, the same as a 404. ########## src/app/features/system/scheduler-jobs/scheduler-job-form.component.ts: ########## @@ -0,0 +1,373 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, DestroyRef, NgZone, OnInit, computed, inject, signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { I18N, SCHEDULER_JOB_API, TranslatePipe } from '../../../core/adapters'; +import { NotificationService } from '../../../core/services/notification.service'; +import { + type CronProblem, + cronDateTimeOf, + formatCronDateTime, + nextFireTimes, + parseQuartzCron, +} from '../../../core/utils/quartz-cron'; +import { LoadErrorComponent } from '../../../shared/components/load-error/load-error.component'; +import { ButtonComponent } from '../../../ui/button/button.component'; +import { SpinnerComponent } from '../../../ui/spinner/spinner.component'; + +/** How many upcoming runs the preview lists. */ +const PREVIEW_COUNT = 3; +/** How often the preview re-reads the clock. */ +const PREVIEW_REFRESH_MS = 30_000; + +/** What the cron field currently says about itself. */ +type CronCheck = + | { readonly state: 'empty' } + | { readonly state: 'invalid'; readonly problem: CronProblem | { readonly kind: 'neverFires' } } + | { readonly state: 'valid'; readonly nextRuns: readonly string[] }; + +/** + * Edits one scheduler job: its display name, its Quartz cron expression and whether it is active. + * + * The expression is checked as it is typed, with the same rules Quartz applies on the server, + * and a valid one previews its next runs. That preview is the point of the screen: a job like + * interest posting or close-of-business that silently stops running is found days later, in the + * accounts, and the next fire times are how an administrator sees that before saving. + */ +@Component({ + selector: 'app-scheduler-job-form', + standalone: true, + imports: [FormsModule, TranslatePipe, ButtonComponent, SpinnerComponent, LoadErrorComponent], + template: ` + <div class="form-container"> + <section class="entity-card"> + <h2>{{ 'SCHEDULER_JOBS.EDIT_JOB' | appTranslate }}</h2> + + @if (isLoading()) { + <app-spinner data-testid="scheduler-job-loading" /> + } @else if (loadFailed()) { + <app-load-error + testId="scheduler-job-load-error" + [message]="'SCHEDULER_JOBS.LOAD_FAILED' | appTranslate" + [actionLabel]="(canRetry() ? 'COMMON.RETRY' : 'COMMON.BACK') | appTranslate" + (action)="canRetry() ? load() : onCancel()" + /> + } @else { + @if (currentlyRunning()) { + <p class="notice" role="status" data-testid="scheduler-job-running"> + {{ 'SCHEDULER_JOBS.CURRENTLY_RUNNING' | appTranslate }} + </p> + } + <form #jobForm="ngForm" (ngSubmit)="onSubmit()"> + <fieldset [disabled]="currentlyRunning()"> + <div class="form-grid"> + <div class="form-field full-width"> + <label for="displayName">{{ + 'SCHEDULER_JOBS.DISPLAY_NAME' | appTranslate + }}</label> + <input + id="displayName" + type="text" + name="displayName" + data-testid="scheduler-job-display-name" + required + [ngModel]="displayName()" + (ngModelChange)="displayName.set($event)" + /> + </div> + + <div class="form-field full-width"> + <label for="cronExpression">{{ + 'SCHEDULER_JOBS.CRON_EXPRESSION' | appTranslate + }}</label> + <input + id="cronExpression" + type="text" + name="cronExpression" + data-testid="scheduler-job-cron" + required + spellcheck="false" + autocomplete="off" + aria-describedby="cronExpression-hint cronExpression-check" + [attr.aria-invalid]="cronCheck().state === 'invalid'" + [ngModel]="cronExpression()" + (ngModelChange)="cronExpression.set($event)" + /> + <span id="cronExpression-hint" class="hint">{{ + 'SCHEDULER_JOBS.CRON_HINT' | appTranslate + }}</span> + <!-- Only the error is announced: the run list changes on every keystroke. --> + <div id="cronExpression-check"> + <span class="error" aria-live="polite" data-testid="scheduler-job-cron-error">{{ + cronError() + }}</span> + @switch (cronCheck().state) { + @case ('valid') { + <span class="hint">{{ 'SCHEDULER_JOBS.NEXT_RUNS' | appTranslate }}</span> + <ul class="next-runs" data-testid="scheduler-job-next-runs"> + @for (run of nextRuns(); track run) { + <li>{{ run }}</li> + } + </ul> + } + } + </div> + </div> + + <div class="form-field full-width"> + <label class="checkbox"> + <input + type="checkbox" + name="active" + data-testid="scheduler-job-active" + [ngModel]="active()" + (ngModelChange)="active.set($event)" + /> + {{ 'SCHEDULER_JOBS.ACTIVE' | appTranslate }} + </label> + </div> + </div> + + <p class="warning">{{ 'SCHEDULER_JOBS.SCHEDULE_WARNING' | appTranslate }}</p> + </fieldset> + + <div class="form-actions"> + <app-button + type="button" + intent="secondary" + emphasis="quiet" + [disabled]="isSaving()" + (click)="onCancel()" + >{{ 'COMMON.CANCEL' | appTranslate }}</app-button + > + <app-button + type="submit" + intent="primary" + data-testid="scheduler-job-save" + [disabled]="!canSave() || !!jobForm.invalid" + >{{ (isSaving() ? 'COMMON.SAVING' : 'COMMON.SAVE') | appTranslate }}</app-button + > + </div> + </form> + } + </section> + </div> + `, + styles: [ + ` + .entity-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + } + h2 { + margin: 0 0 16px; + font-size: 1.1rem; + } + fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + } + .hint { + font-size: 0.75rem; + color: var(--text-muted); + } + .error { + font-size: 0.75rem; + color: var(--error-color); + } + .next-runs { + margin: 4px 0 0; + padding-inline-start: 20px; + font-family: monospace; + font-size: 0.85rem; + } + .form-field label.checkbox { + flex-direction: row; + font-size: 0.9rem; + color: var(--text-color); + } + .form-field label.checkbox input { + width: auto; + } + .notice, + .warning { + font-size: 0.85rem; + color: var(--text-muted); + } + `, + ], +}) +export class SchedulerJobFormComponent implements OnInit { + private readonly jobApi = inject(SCHEDULER_JOB_API); + private readonly notifications = inject(NotificationService); + private readonly i18n = inject(I18N); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + private readonly LIST_PATH = '/system/scheduler-jobs'; + + private jobId?: number; + /** False when no retry can load the job (a malformed id, or a 404); the action leaves instead. */ + readonly canRetry = signal(true); + + readonly displayName = signal(''); + readonly cronExpression = signal(''); + readonly active = signal(false); + readonly currentlyRunning = signal(false); + + readonly isLoading = signal(false); + readonly loadFailed = signal(false); + readonly isSaving = signal(false); + + /** The clock the preview counts from, re-read so a form left open does not list past runs. */ + private readonly now = signal(new Date()); + + readonly cronCheck = computed<CronCheck>(() => { + const expression = this.cronExpression().trim(); + if (!expression) return { state: 'empty' }; + const parsed = parseQuartzCron(expression); + if (!parsed.ok) return { state: 'invalid', problem: parsed.problem }; + // Fineract fires in the tenant's time zone, which the screen does not know; the browser's + // clock is the nearest "now" it has, so the first run listed can be off by the zone gap. + const runs = nextFireTimes(parsed.schedule, cronDateTimeOf(this.now()), PREVIEW_COUNT); + if (runs.length === 0) return { state: 'invalid', problem: { kind: 'neverFires' } }; + return { state: 'valid', nextRuns: runs.map(formatCronDateTime) }; + }); + + readonly nextRuns = computed(() => { + const check = this.cronCheck(); + return check.state === 'valid' ? check.nextRuns : []; + }); + + readonly cronError = computed(() => { + const check = this.cronCheck(); + if (check.state !== 'invalid') return ''; + const problem = check.problem; + switch (problem.kind) { + case 'fieldCount': + return this.i18n.translate('SCHEDULER_JOBS.CRON_FIELD_COUNT'); + case 'dayConflict': + return this.i18n.translate('SCHEDULER_JOBS.CRON_DAY_CONFLICT'); + case 'neverFires': + return this.i18n.translate('SCHEDULER_JOBS.CRON_NEVER_FIRES'); + case 'field': + return this.i18n.translate('SCHEDULER_JOBS.CRON_INVALID_FIELD', { + field: this.i18n.translate(CRON_FIELD_LABELS[problem.field]), + value: problem.value, + }); + } + }); + + readonly canSave = computed( + () => + !this.isSaving() && + !this.currentlyRunning() && + this.displayName().trim() !== '' && + this.cronCheck().state === 'valid', + ); + + constructor() { + // Outside the zone, so a timer that never ends does not keep the page from going stable; + // the signal write still schedules change detection. + const tick = inject(NgZone).runOutsideAngular(() => + setInterval(() => this.now.set(new Date()), PREVIEW_REFRESH_MS), + ); + inject(DestroyRef).onDestroy(() => clearInterval(tick)); + } + + ngOnInit(): void { + const id = Number(this.route.snapshot.paramMap.get('id')); + if (Number.isInteger(id) && id > 0) { + this.jobId = id; + this.load(); + } else { + // Nothing to retry: a malformed id never loads. + this.canRetry.set(false); + this.loadFailed.set(true); + } + } + + load(): void { + if (!this.jobId) return; + this.isLoading.set(true); + this.loadFailed.set(false); + this.jobApi.get(this.jobId).subscribe({ + next: (job) => { + this.displayName.set(job.displayName); + this.cronExpression.set(job.cronExpression); + this.active.set(job.active); + this.currentlyRunning.set(job.currentlyRunning); Review Comment: Changed: the form re-reads the job just before the PUT and refuses to save if it is now running, with a message to reload once the run finishes. The warning text now says that saving during a run can let a second run start. ########## src/app/features/system/scheduler-jobs/scheduler-job-form.component.ts: ########## @@ -0,0 +1,373 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, DestroyRef, NgZone, OnInit, computed, inject, signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { I18N, SCHEDULER_JOB_API, TranslatePipe } from '../../../core/adapters'; +import { NotificationService } from '../../../core/services/notification.service'; +import { + type CronProblem, + cronDateTimeOf, + formatCronDateTime, + nextFireTimes, + parseQuartzCron, +} from '../../../core/utils/quartz-cron'; +import { LoadErrorComponent } from '../../../shared/components/load-error/load-error.component'; +import { ButtonComponent } from '../../../ui/button/button.component'; +import { SpinnerComponent } from '../../../ui/spinner/spinner.component'; + +/** How many upcoming runs the preview lists. */ +const PREVIEW_COUNT = 3; +/** How often the preview re-reads the clock. */ +const PREVIEW_REFRESH_MS = 30_000; + +/** What the cron field currently says about itself. */ +type CronCheck = + | { readonly state: 'empty' } + | { readonly state: 'invalid'; readonly problem: CronProblem | { readonly kind: 'neverFires' } } + | { readonly state: 'valid'; readonly nextRuns: readonly string[] }; + +/** + * Edits one scheduler job: its display name, its Quartz cron expression and whether it is active. + * + * The expression is checked as it is typed, with the same rules Quartz applies on the server, + * and a valid one previews its next runs. That preview is the point of the screen: a job like + * interest posting or close-of-business that silently stops running is found days later, in the + * accounts, and the next fire times are how an administrator sees that before saving. + */ +@Component({ + selector: 'app-scheduler-job-form', + standalone: true, + imports: [FormsModule, TranslatePipe, ButtonComponent, SpinnerComponent, LoadErrorComponent], + template: ` + <div class="form-container"> + <section class="entity-card"> + <h2>{{ 'SCHEDULER_JOBS.EDIT_JOB' | appTranslate }}</h2> + + @if (isLoading()) { + <app-spinner data-testid="scheduler-job-loading" /> + } @else if (loadFailed()) { + <app-load-error + testId="scheduler-job-load-error" + [message]="'SCHEDULER_JOBS.LOAD_FAILED' | appTranslate" + [actionLabel]="(canRetry() ? 'COMMON.RETRY' : 'COMMON.BACK') | appTranslate" + (action)="canRetry() ? load() : onCancel()" + /> + } @else { + @if (currentlyRunning()) { + <p class="notice" role="status" data-testid="scheduler-job-running"> + {{ 'SCHEDULER_JOBS.CURRENTLY_RUNNING' | appTranslate }} + </p> + } + <form #jobForm="ngForm" (ngSubmit)="onSubmit()"> + <fieldset [disabled]="currentlyRunning()"> + <div class="form-grid"> + <div class="form-field full-width"> + <label for="displayName">{{ + 'SCHEDULER_JOBS.DISPLAY_NAME' | appTranslate + }}</label> + <input + id="displayName" + type="text" + name="displayName" + data-testid="scheduler-job-display-name" + required + [ngModel]="displayName()" + (ngModelChange)="displayName.set($event)" + /> + </div> + + <div class="form-field full-width"> + <label for="cronExpression">{{ + 'SCHEDULER_JOBS.CRON_EXPRESSION' | appTranslate + }}</label> + <input + id="cronExpression" + type="text" + name="cronExpression" + data-testid="scheduler-job-cron" + required + spellcheck="false" + autocomplete="off" + aria-describedby="cronExpression-hint cronExpression-check" + [attr.aria-invalid]="cronCheck().state === 'invalid'" + [ngModel]="cronExpression()" + (ngModelChange)="cronExpression.set($event)" + /> + <span id="cronExpression-hint" class="hint">{{ + 'SCHEDULER_JOBS.CRON_HINT' | appTranslate + }}</span> + <!-- Only the error is announced: the run list changes on every keystroke. --> + <div id="cronExpression-check"> + <span class="error" aria-live="polite" data-testid="scheduler-job-cron-error">{{ + cronError() + }}</span> + @switch (cronCheck().state) { + @case ('valid') { + <span class="hint">{{ 'SCHEDULER_JOBS.NEXT_RUNS' | appTranslate }}</span> + <ul class="next-runs" data-testid="scheduler-job-next-runs"> + @for (run of nextRuns(); track run) { + <li>{{ run }}</li> + } + </ul> + } + } + </div> + </div> + + <div class="form-field full-width"> + <label class="checkbox"> + <input + type="checkbox" + name="active" + data-testid="scheduler-job-active" + [ngModel]="active()" + (ngModelChange)="active.set($event)" + /> + {{ 'SCHEDULER_JOBS.ACTIVE' | appTranslate }} + </label> + </div> + </div> + + <p class="warning">{{ 'SCHEDULER_JOBS.SCHEDULE_WARNING' | appTranslate }}</p> + </fieldset> + + <div class="form-actions"> + <app-button + type="button" + intent="secondary" + emphasis="quiet" + [disabled]="isSaving()" + (click)="onCancel()" + >{{ 'COMMON.CANCEL' | appTranslate }}</app-button + > + <app-button + type="submit" + intent="primary" + data-testid="scheduler-job-save" + [disabled]="!canSave() || !!jobForm.invalid" + >{{ (isSaving() ? 'COMMON.SAVING' : 'COMMON.SAVE') | appTranslate }}</app-button + > + </div> + </form> + } + </section> + </div> + `, + styles: [ + ` + .entity-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + } + h2 { + margin: 0 0 16px; + font-size: 1.1rem; + } + fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + } + .hint { + font-size: 0.75rem; + color: var(--text-muted); + } + .error { + font-size: 0.75rem; + color: var(--error-color); + } + .next-runs { + margin: 4px 0 0; + padding-inline-start: 20px; + font-family: monospace; + font-size: 0.85rem; + } + .form-field label.checkbox { + flex-direction: row; + font-size: 0.9rem; + color: var(--text-color); + } + .form-field label.checkbox input { + width: auto; + } + .notice, + .warning { + font-size: 0.85rem; + color: var(--text-muted); + } + `, + ], +}) +export class SchedulerJobFormComponent implements OnInit { + private readonly jobApi = inject(SCHEDULER_JOB_API); + private readonly notifications = inject(NotificationService); + private readonly i18n = inject(I18N); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + private readonly LIST_PATH = '/system/scheduler-jobs'; + + private jobId?: number; + /** False when no retry can load the job (a malformed id, or a 404); the action leaves instead. */ + readonly canRetry = signal(true); + + readonly displayName = signal(''); + readonly cronExpression = signal(''); + readonly active = signal(false); + readonly currentlyRunning = signal(false); + + readonly isLoading = signal(false); + readonly loadFailed = signal(false); + readonly isSaving = signal(false); + + /** The clock the preview counts from, re-read so a form left open does not list past runs. */ + private readonly now = signal(new Date()); + + readonly cronCheck = computed<CronCheck>(() => { + const expression = this.cronExpression().trim(); + if (!expression) return { state: 'empty' }; + const parsed = parseQuartzCron(expression); + if (!parsed.ok) return { state: 'invalid', problem: parsed.problem }; + // Fineract fires in the tenant's time zone, which the screen does not know; the browser's + // clock is the nearest "now" it has, so the first run listed can be off by the zone gap. + const runs = nextFireTimes(parsed.schedule, cronDateTimeOf(this.now()), PREVIEW_COUNT); + if (runs.length === 0) return { state: 'invalid', problem: { kind: 'neverFires' } }; + return { state: 'valid', nextRuns: runs.map(formatCronDateTime) }; + }); + + readonly nextRuns = computed(() => { + const check = this.cronCheck(); + return check.state === 'valid' ? check.nextRuns : []; + }); + + readonly cronError = computed(() => { + const check = this.cronCheck(); + if (check.state !== 'invalid') return ''; + const problem = check.problem; + switch (problem.kind) { + case 'fieldCount': + return this.i18n.translate('SCHEDULER_JOBS.CRON_FIELD_COUNT'); + case 'dayConflict': + return this.i18n.translate('SCHEDULER_JOBS.CRON_DAY_CONFLICT'); + case 'neverFires': + return this.i18n.translate('SCHEDULER_JOBS.CRON_NEVER_FIRES'); + case 'field': + return this.i18n.translate('SCHEDULER_JOBS.CRON_INVALID_FIELD', { + field: this.i18n.translate(CRON_FIELD_LABELS[problem.field]), + value: problem.value, + }); + } + }); + + readonly canSave = computed( + () => + !this.isSaving() && + !this.currentlyRunning() && + this.displayName().trim() !== '' && + this.cronCheck().state === 'valid', + ); + + constructor() { + // Outside the zone, so a timer that never ends does not keep the page from going stable; + // the signal write still schedules change detection. + const tick = inject(NgZone).runOutsideAngular(() => + setInterval(() => this.now.set(new Date()), PREVIEW_REFRESH_MS), + ); + inject(DestroyRef).onDestroy(() => clearInterval(tick)); + } + + ngOnInit(): void { + const id = Number(this.route.snapshot.paramMap.get('id')); + if (Number.isInteger(id) && id > 0) { + this.jobId = id; + this.load(); + } else { + // Nothing to retry: a malformed id never loads. + this.canRetry.set(false); + this.loadFailed.set(true); + } + } + + load(): void { + if (!this.jobId) return; + this.isLoading.set(true); + this.loadFailed.set(false); + this.jobApi.get(this.jobId).subscribe({ + next: (job) => { + this.displayName.set(job.displayName); + this.cronExpression.set(job.cronExpression); + this.active.set(job.active); + this.currentlyRunning.set(job.currentlyRunning); + this.isLoading.set(false); + }, + error: (error: unknown) => { + // A job that does not exist will not appear on retry. + this.canRetry.set(!(error instanceof HttpErrorResponse && error.status === 404)); + this.loadFailed.set(true); + this.isLoading.set(false); + }, + }); + } + + onSubmit(): void { + // The button is disabled in these states, but a second Enter can land before it is. + if (!this.jobId || !this.canSave()) return; + this.isSaving.set(true); + this.jobApi + .update(this.jobId, { + displayName: this.displayName().trim(), + cronExpression: this.cronExpression().trim(), + active: this.active(), + }) + .subscribe({ + next: (outcome) => { + void this.notifications.success( + this.i18n.translate( + outcome.pendingApproval + ? 'COMMON.MUTATION_SUBMITTED_FOR_APPROVAL' Review Comment: Changed both. The approval message now says Fineract applies an approved schedule change only after a restart. There is also a unit test for a 400 on save: Save is enabled again, no success toast, no navigation. I have not filed the Fineract Jira for the missing `rescheduleJob` call yet. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
