Aman-Mittal commented on code in PR #838:
URL:
https://github.com/apache/fineract-backoffice-ui/pull/838#discussion_r4385346112
##########
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:
The form's first call is `GET /jobs/{id}`, and Fineract's `retrieveOne`
requires `READ_SCHEDULER`. A role with `UPDATE_SCHEDULER` but not
`READ_SCHEDULER` passes this guard via a direct URL, gets a 403 on load, and is
offered "Try again", which can never succeed (`canRetry` is false only for a
404, form L327).
Please use `permissions: ['READ_SCHEDULER', 'UPDATE_SCHEDULER'],
permissionsMatchAll: true`, and treat a 403 as non-retryable too. Minor.
##########
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:
**The preview counts from the browser's clock (`now = signal(new Date())`,
L246), but Quartz fires in the tenant's time zone.** `JobRegisterServiceImpl`
builds the trigger with `TimeZone.getTimeZone(tenant.getTimezoneId())`. The app
already has `ConfigService.config().tenantTimezone` and
`PlatformDateService.serverTime()`, so the "screen doesn't know the tenant time
zone" note in the description isn't quite right.
For example, an admin in Europe editing `0 0 1 * * ?` on an IST tenant sees
"next runs" that are hours off. The list can show a run that already happened
today or skip one, and the times have no zone label. The preview is the main
point of this screen.
Please compute "now" as wall-clock time in `tenantTimezone` (via
`Intl.DateTimeFormat(..., { timeZone })`), starting from `serverTime() ?? new
Date()`, and label the list with the zone, e.g. "Next runs (tenant time,
Asia/Kolkata)".
##########
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:
**Real-backend coverage.** This mock answers `{ resourceId: 1, changes: {}
}`, which Fineract doesn't send for a real change, and the description says the
feature hasn't been run against Fineract. This screen reschedules real jobs, so
please add a spec to `BACKEND_SPECS` that:
- changes a harmless job's cron and asserts the list's Next Run changes;
- asserts Fineract's 400 (`validation.msg.jobs.cronExpression.invalid`)
surfaces when an invalid expression is PUT directly;
- restores the original values in `afterAll`.
The mocked spec could also cover a 400 on save.
##########
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:
Two small ones:
- **Maker-checker wording.** In Fineract, the reschedule happens only in the
API resource when the PUT itself returns changes. When a checker later
approves, `UpdateJobDetailCommandhandler` updates the row but never calls
`rescheduleJob`, so Quartz keeps the old trigger until a restart. "Submitted
for approval." suggests the new schedule takes effect on approval. Worth saying
it applies after approval *and* a restart (and a Fineract Jira for the
underlying bug). `UPDATE_SCHEDULER` isn't maker-checker by default, so this is
rare.
- **Failed-save test.** `error: () => this.isSaving.set(false)` (L356) has
no test. One case with a 400 `HttpErrorResponse` should assert Save re-enables,
there's no success toast, and the user stays on the form.
##########
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:
**Every save is blocked on a client-valid cron, and always sends all three
fields (L339–343).** Fineract's `JobDetailDataValidator.validateForUpdate`
checks each parameter only if it's present (`parameterExists`), and
`ScheduledJobDetail.update` applies only the fields that changed, so partial
updates are fine server-side. Two consequences of the current approach:
- If a job's stored expression is one Quartz accepts but this parser refuses
(it is stricter by design, e.g. some step or year forms), the form opens with a
cron error and Save stays disabled. The admin then **can't untick Active to
stop the job**, which is the emergency action this screen should always allow.
- Two admins editing the same job: the second save writes the first one's
display name and cron back with stale values.
Please keep the loaded values, send only the fields that changed, require
`cronCheck().state === 'valid'` only when the cron itself changed, and disable
Save when nothing changed. A unit test for "stored cron refused, untick Active,
Save sends `{ active: false }` only" would pin it.
##########
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:
Minor: the "running job is read-only" check happens only at load. Fineract
doesn't refuse a PUT while a job runs. On save it reschedules (delete and
schedule the trigger), and that path sets `currentlyRunning = false`. So if a
COB or interest-posting run starts while the form is open, saving clears the
flag mid-run and another run can start on top.
Re-fetching the job just before the PUT and refusing if it's now running
narrows the window a lot. Please also mention the risk in the warning text.
--
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]