Skip to content

Commit d374fcd

Browse files
committed
NAS-141483: Address review — result-derived successMessage, trim restated comments
Claude-Session: https://claude.ai/code/session_01SUZQL1eT6RcmdRR59eYxpY
1 parent 1d90cb0 commit d374fcd

9 files changed

Lines changed: 76 additions & 44 deletions

File tree

src/app/modules/forms/ix-forms/components/ix-form/ix-form.component.spec.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,8 +1247,9 @@ describe('IxFormComponent', () => {
12471247
// This block asserts the delay itself, so restore the real duration that
12481248
// `ixFormTestingProviders()` zeroes for every other spec.
12491249
{ provide: ixFormMinSubmitFeedbackMs, useValue: defaultMinSubmitFeedbackMs },
1250-
// Force the `<tn-side-panel>` host: no SlideInRef (the harness would otherwise auto-mock
1251-
// one, taking the un-delayed legacy path). `null` is what `inject(…, {optional:true})` sees.
1250+
// Force the `<tn-side-panel>` host: `null` is exactly what `inject(SlideInRef,
1251+
// {optional: true})` sees when nothing provides one, and stating it here keeps the block
1252+
// independent of whatever the surrounding suite happens to provide.
12521253
{ provide: SlideInRef, useValue: null },
12531254
mockAuth(),
12541255
],
@@ -1362,5 +1363,36 @@ describe('IxFormComponent', () => {
13621363
expect(warn).not.toHaveBeenCalled();
13631364
warn.mockRestore();
13641365
});
1366+
1367+
it('builds the snackbar from the request result when successMessage is a function', () => {
1368+
submitHandlerSpy.mockReturnValue({
1369+
request$: of({ id: 1, name: 'saved-record' }),
1370+
successMessage: (result: { name: string }) => `Saved «${result.name}».` as TranslatedString,
1371+
});
1372+
1373+
const sidePanelSpectator = createSidePanelComponent({
1374+
providers: [{ provide: ixFormMinSubmitFeedbackMs, useValue: 0 }],
1375+
});
1376+
sidePanelSpectator.component.ixForm().submit();
1377+
1378+
expect(sidePanelSpectator.inject(SnackbarService).success).toHaveBeenCalledWith('Saved «saved-record».');
1379+
});
1380+
1381+
it('stays silent without warning when a successMessage function returns null', () => {
1382+
// A function returning `null` decided, for this result, that no confirmation is wanted (the
1383+
// dataset form does exactly that when the save navigates on to the ACL editor). That's an
1384+
// explicit choice, unlike a statically `null` message, so it must not dev-warn.
1385+
const warn = jest.spyOn(console, 'warn').mockImplementation();
1386+
submitHandlerSpy.mockReturnValue({ request$: of({ id: 1 }), successMessage: () => null });
1387+
1388+
const sidePanelSpectator = createSidePanelComponent({
1389+
providers: [{ provide: ixFormMinSubmitFeedbackMs, useValue: 0 }],
1390+
});
1391+
sidePanelSpectator.component.ixForm().submit();
1392+
1393+
expect(sidePanelSpectator.inject(SnackbarService).success).not.toHaveBeenCalled();
1394+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('null successMessage'));
1395+
warn.mockRestore();
1396+
});
13651397
});
13661398
});

src/app/modules/forms/ix-forms/components/ix-form/ix-form.component.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,16 @@ interface SubmitResultBase<R, TResult> {
8282
request$: Observable<TResult>;
8383

8484
/**
85-
* Success snackbar text. Required, but nullable: pass `null` — visibly, at the callsite — for a
86-
* form that raises its own snackbar (or deliberately raises none) under
87-
* `[suppressSuccessSnackbar]`. A `null` without that input set is a silent save, and warns in
88-
* dev mode.
85+
* Success snackbar text — a string, or a function of the request result for the common case of a
86+
* confirmation that names the saved record (which a static string can't reach).
87+
*
88+
* Required, but nullable: pass `null` — visibly, at the callsite — for a form that raises its own
89+
* snackbar (or deliberately raises none) under `[suppressSuccessSnackbar]`. A `null` without that
90+
* input set is a silent save, and warns in dev mode. The function form may also return `null`,
91+
* for a save whose confirmation depends on the outcome (e.g. none when the success path navigates
92+
* away); that's an explicit per-result decision, so it never warns.
8993
*/
90-
successMessage: TranslatedString | null;
94+
successMessage: TranslatedString | ((result: TResult) => TranslatedString | null) | null;
9195

9296
/** Runs after success, before close (store/navigation fire pre-animation). */
9397
onSuccess?: (result: TResult) => void;
@@ -416,9 +420,12 @@ export class IxFormComponent<
416420
handledSuccess = true;
417421
this.hadSuccessfulSubmit = true;
418422
if (!this.suppressSuccessSnackbar()) {
419-
if (successMessage) {
420-
this.snackbar.success(successMessage);
421-
} else if (isDevMode()) {
423+
const message = typeof successMessage === 'function' ? successMessage(result) : successMessage;
424+
if (message) {
425+
this.snackbar.success(message);
426+
} else if (successMessage === null && isDevMode()) {
427+
// Only a statically `null` successMessage warns — a function that returned `null` chose
428+
// silence for this particular result, which is a supported outcome.
422429
console.warn(
423430
'[ix-form] submitHandler returned a null successMessage and suppressSuccessSnackbar is not '
424431
+ 'set, so this save gives the user no confirmation. Provide a successMessage, or set '

src/app/pages/datasets/components/dataset-capacity-management-card/dataset-capacity-settings/dataset-capacity-settings.component.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ export class DatasetCapacitySettingsComponent extends IxFormHostForm implements
4141
private validators = inject(IxValidatorsService);
4242
private destroyRef = inject(DestroyRef);
4343

44-
/** Read by the `<tn-side-panel>` host to role-gate its footer Save. */
4544
readonly requiredRoles = [Role.DatasetWrite];
4645
protected readonly InputType = InputType;
4746

src/app/pages/datasets/components/dataset-form/dataset-form.component.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
[formGroup]="form"
44
[externalLoading]="isLoading()"
55
[extraDisabled]="!areSubFormsValid()"
6-
[suppressSuccessSnackbar]="true"
76
[submitHandler]="handleSubmit"
87
(closed)="onFormClosed($event)"
98
>

src/app/pages/datasets/components/dataset-form/dataset-form.component.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,8 @@ describe('DatasetFormComponent', () => {
334334
});
335335

336336
describe('success messages', () => {
337-
// The form saves with a `null` successMessage under `[suppressSuccessSnackbar]` (its message
338-
// needs the saved record), so these snackbars are the ONLY confirmation a save produces.
337+
// These snackbars are the ONLY confirmation a save produces, and they are built from the saved
338+
// record by `successMessage`'s function form.
339339
// Declining the ACL prompt keeps us off the navigate-away branch, which is deliberately silent.
340340
const declineAclPrompt = [mockProvider(DialogService, { confirm: jest.fn(() => of(false)) })];
341341

src/app/pages/datasets/components/dataset-form/dataset-form.component.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
FormSubmitEvent, IxFormComponent, SubmitResult,
2424
} from 'app/modules/forms/ix-forms/components/ix-form/ix-form.component';
2525
import { SidePanelFooterAction } from 'app/modules/slide-ins/form-side-panel/form-side-panel-container.component';
26-
import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service';
2726
import { ApiService } from 'app/modules/websocket/api.service';
2827
import {
2928
EncryptionSectionComponent,
@@ -69,7 +68,6 @@ export class DatasetFormComponent extends IxFormHostForm<Dataset | null> impleme
6968
private datasetFormService = inject(DatasetFormService);
7069
private router = inject(Router);
7170
private errorHandler = inject(ErrorHandlerService);
72-
private snackbar = inject(SnackbarService);
7371
private translate = inject(TranslateService);
7472
private store$ = inject<Store<AppState>>(Store);
7573

@@ -83,7 +81,6 @@ export class DatasetFormComponent extends IxFormHostForm<Dataset | null> impleme
8381
private quotasSection = viewChild(QuotasSectionComponent);
8482
private otherOptionsSection = viewChild(OtherOptionsSectionComponent);
8583

86-
/** Read by the `<tn-side-panel>` host to role-gate its footer Save. */
8784
readonly requiredRoles = [Role.DatasetWrite];
8885

8986
protected readonly isNameAndOptionsValid = signal(true);
@@ -293,9 +290,19 @@ export class DatasetFormComponent extends IxFormHostForm<Dataset | null> impleme
293290

294291
return {
295292
request$: this.saveDataset(request$),
296-
// The message depends on the saved record, so `onSaved` raises it instead — the form runs
297-
// under `[suppressSuccessSnackbar]`, which is what makes this `null` deliberate.
298-
successMessage: null,
293+
// Owned by the form (not its openers) so every entry point confirms identically, and phrased
294+
// as plain "created"/"updated" to match the zvol form — a message that has to hold for every
295+
// opener can't assume the user is being taken to the new record.
296+
successMessage: ([savedDataset, shouldGoToAclEditor]) => {
297+
// Accepting the ACL prompt navigates to the ACL editor; a toast about the dataset would
298+
// land on a page the user has already left.
299+
if (shouldGoToAclEditor) {
300+
return null;
301+
}
302+
return this.isNew()
303+
? this.translate.instant('Dataset «{name}» created.', { name: getDatasetLabel(savedDataset) })
304+
: this.translate.instant('Dataset «{name}» updated.', { name: getDatasetLabel(savedDataset) });
305+
},
299306
onSuccess: ([savedDataset, shouldGoToAclEditor]) => this.onSaved(savedDataset, shouldGoToAclEditor),
300307
closeWith: ([savedDataset]) => savedDataset,
301308
onError: (error: unknown) => {
@@ -334,17 +341,6 @@ export class DatasetFormComponent extends IxFormHostForm<Dataset | null> impleme
334341
}
335342

336343
this.aclEditorPath = shouldGoToAclEditor ? savedDataset.mountpoint : undefined;
337-
338-
if (!shouldGoToAclEditor) {
339-
// Phrased as plain "created", matching the zvol form: now that the forms own their messages
340-
// they must hold for every opener, and not all of them navigate to the new record (the
341-
// explorer's Create Zvol just fills the field in place).
342-
this.snackbar.success(
343-
this.isNew()
344-
? this.translate.instant('Dataset «{name}» created.', { name: getDatasetLabel(savedDataset) })
345-
: this.translate.instant('Dataset «{name}» updated.', { name: getDatasetLabel(savedDataset) }),
346-
);
347-
}
348344
}
349345

350346
/**

src/app/pages/datasets/components/zvol-form/zvol-form.component.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,9 @@ describe('ZvolFormComponent', () => {
157157
],
158158
providers: [
159159
mockApi([
160-
mockCall('pool.dataset.create', { id: 'parentId/new zvol' } as Dataset),
161-
mockCall('pool.dataset.update', { id: 'zvolId' } as Dataset),
160+
// `name` matters: the success snackbars are built from the saved record, not the payload.
161+
mockCall('pool.dataset.create', { id: 'parentId/new zvol', name: 'parentId/new zvol' } as Dataset),
162+
mockCall('pool.dataset.update', { id: 'zvolId', name: 'zvolId' } as Dataset),
162163
mockCall('pool.dataset.recommended_zvol_blocksize', '16K' as DatasetRecordSize),
163164
mockCall('pool.dataset.query', (params) => {
164165
if ((params[0][0] as QueryFilter<Dataset>)[2] === 'parentId') {

src/app/pages/datasets/components/zvol-form/zvol-form.component.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ export class ZvolFormComponent extends IxFormHostForm<Dataset> implements OnInit
100100

101101
private destroyRef = inject(DestroyRef);
102102

103-
/** Read by the `<tn-side-panel>` host to role-gate its footer Save. */
104103
readonly requiredRoles = [Role.DatasetWrite];
105104

106105
/** Edit/create parameters supplied by the `<tn-side-panel>` host. */
@@ -266,9 +265,10 @@ export class ZvolFormComponent extends IxFormHostForm<Dataset> implements OnInit
266265
return {
267266
request$: this.api.call('pool.dataset.create', [data as DatasetCreate]),
268267
// Owned by the form so every entry point (details panel, details card, explorer) confirms
269-
// identically — the openers deliberately raise no snackbar of their own.
270-
successMessage: this.translate.instant('Zvol «{name}» created.', {
271-
name: getDatasetLabel({ name: data.name ?? '' }),
268+
// identically — the openers deliberately raise no snackbar of their own. Named from the
269+
// created record rather than the payload, so the toast can't drift from what was saved.
270+
successMessage: (created) => this.translate.instant('Zvol «{name}» created.', {
271+
name: getDatasetLabel(created),
272272
}),
273273
// The opener needs the record to switch to the new zvol.
274274
closeWith: (result) => result,
@@ -286,12 +286,11 @@ export class ZvolFormComponent extends IxFormHostForm<Dataset> implements OnInit
286286
return this.api.call('pool.dataset.update', [this.parentOrZvolId(), payload]);
287287
}),
288288
),
289-
// See `buildCreateResult` — the message is the form's, not the opener's. On edit
290-
// `parentOrZvolId()` IS the zvol's own id (create passes the parent), and it names the zvol
291-
// correctly only because the Name control is disabled in edit mode — a rename would have to
292-
// read the submitted value instead.
293-
successMessage: this.translate.instant('Zvol «{name}» updated.', {
294-
name: getDatasetLabel({ name: this.parentOrZvolId() }),
289+
// See `buildCreateResult` — the message is the form's, not the opener's, and it names the
290+
// updated record rather than `parentOrZvolId()`, so it stays correct if Name ever becomes
291+
// editable on edit.
292+
successMessage: (updated) => this.translate.instant('Zvol «{name}» updated.', {
293+
name: getDatasetLabel(updated),
295294
}),
296295
closeWith: (result) => result,
297296
onError: (error: unknown) => {

src/app/pages/datasets/modules/snapshots/snapshot-add-form/snapshot-add-form.component.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export class SnapshotAddFormComponent extends IxFormHostForm implements OnInit {
6565
private storageService = inject(StorageService);
6666
private destroyRef = inject(DestroyRef);
6767

68-
/** Read by the `<tn-side-panel>` host to role-gate its footer Save. */
6968
readonly requiredRoles = [Role.SnapshotWrite];
7069

7170
/** Initial options load. Drives the panel's progress bar and busy overlay. */

0 commit comments

Comments
 (0)