Skip to content

Initial SaveButtonComponent implementation #3261

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: feature/ng19-forms
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d50221f
Initial Tab placeholder
shilob Jul 22, 2025
6d0a9a4
Merge branch 'feature/ng19-forms' into feature/ng19-forms_tabcontainer
shilob Jul 22, 2025
df4253f
Added TabComponent definition and model. Models are now optional for …
shilob Jul 22, 2025
9f6ba77
TabComponent properly enumerates and intialises content's components.…
shilob Jul 30, 2025
c040336
Refactor getDebugInfo to handle components generically instead of har…
shilob Jul 30, 2025
93481a7
Enhance TabComponent with configurable CSS classes and active state f…
shilob Jul 30, 2025
668a5aa
Implement TabComponent updates: replace 'active' with 'selected' for …
shilob Jul 31, 2025
a3725e2
Add tab switching functionality and improve component definition retr…
shilob Aug 4, 2025
da9935b
Merge branch 'feature/ng19-forms' into feature/ng19-forms_tabcontainer
shilob Aug 4, 2025
0e945e9
Updated comments in FormService.
shilob Aug 5, 2025
a8b97cf
Work in progress: Add SaveButtonComponent and integrate with form han…
shilob Aug 11, 2025
e029b9d
Merge branch 'feature/ng19-forms' into feature/ng19-forms_save-button
shilob Aug 14, 2025
ba8df78
Refactor FormComponent to use signals for state management and enhanc…
shilob Aug 14, 2025
fbd47f4
Refactor saveForm method to include status checks and skipValidation …
shilob Aug 14, 2025
4434e19
Implemented SaveButtonComponent 'targetStep', 'forceSave', and 'skipV…
shilob Aug 18, 2025
7f7db19
Enhance FormComponent to include saveResponse signal during form subm…
shilob Aug 18, 2025
b0931d7
Restored repeatable definition outside of tabs
shilob Aug 18, 2025
d38b170
Merge branch 'feature/ng19-forms' into feature/ng19-forms_save-button
shilob Aug 18, 2025
004951e
Refactor FormComponent to use trimmedParams for data model retrieval …
shilob Aug 18, 2025
7c6aeb9
Simplified SaveButton disable logic
shilob Aug 18, 2025
40988e7
Remove console log statements from SaveButtonComponent and RecordService
shilob Aug 18, 2025
1075a17
Refactor trimStringSignal to handle nullish values and improve defaul…
shilob Aug 19, 2025
7bfbdc4
Enhance OID update effect to reinitialize only when form is in 'READY…
shilob Aug 19, 2025
6387218
Merge branch 'feature/ng19-forms' into feature/ng19-forms_save-button
shilob Aug 19, 2025
4a1d733
Fixed: debug info not displaying children of container-like components
shilob Aug 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {FormConfig} from '@researchdatabox/sails-ng-common';
import {SaveButtonComponent} from './save-button.component';
import {TextFieldComponent} from './textfield.component';

import {createFormAndWaitForReady, createTestbedModule} from "../helpers.spec";
import {TestBed} from "@angular/core/testing";

let formConfig: FormConfig;

describe('SaveButtonComponent', () => {
beforeEach(async () => {
await createTestbedModule([
TextFieldComponent,
SaveButtonComponent
]);
formConfig = {
debugValue: true,
domElementType: 'form',
defaultComponentConfig: {
defaultComponentCssClasses: 'row',
},
editCssClasses: "redbox-form form",
componentDefinitions: [
{
name: 'text_1_event',
model: {
class: 'TextFieldModel',
config: {
value: 'hello world saved!',
defaultValue: 'hello world default!'
}
},
component: {
class: 'TextFieldComponent'
}
},
{
name: 'save_button',
component: {
class: 'SaveButtonComponent',
config: {
label: 'Save',
targetStep: 'next_step',
forceSave: true,
skipValidation: true
}
}
}
]
};
});

it('should create SaveButtonComponent', () => {
let fixture = TestBed.createComponent(SaveButtonComponent);
let component = fixture.componentInstance;
expect(component).toBeDefined();
});

it('clicking save button should save form', async () => {
const {fixture, formComponent, componentDefinitions} = await createFormAndWaitForReady(formConfig);
// Intercept the formComponent.saveForm method
spyOn<any>(formComponent, 'saveForm');
// Simulate a change in the text field
const textField = fixture.nativeElement.querySelector('input');
textField.value = 'new value';
textField.dispatchEvent(new Event('input'));
fixture.detectChanges();
// Simulate the save button click
const saveButton = fixture.nativeElement.querySelector('button');
saveButton.click();
fixture.detectChanges();
// Assert that saveForm was called with the expected params
expect(formComponent.saveForm).toHaveBeenCalledWith(true, 'next_step', true);
});

it('clicking save button should be disabled when the form is unchanged', async () => {
const {fixture, formComponent, componentDefinitions} = await createFormAndWaitForReady(formConfig);
// Intercept the formComponent.saveForm method
spyOn<any>(formComponent, 'saveForm');
// Simulate the save button click
const saveButton = fixture.nativeElement.querySelector('button');
saveButton.click();
fixture.detectChanges();
// Assert that saveForm was not called
expect(formComponent.saveForm).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Component, inject } from '@angular/core';
import { FormFieldBaseComponent } from '@researchdatabox/portal-ng-common';
import { FormComponent } from '../form.component';
import { SaveButtonComponentDefinition } from '@researchdatabox/sails-ng-common';

@Component({
selector: 'redbox-form-save-button',
template:`
@if (getBooleanProperty('visible')) {
<ng-container *ngTemplateOutlet="getTemplateRef('before')" />
<button type="button" class="btn btn-primary" (click)="save()" [innerHtml]="getStringProperty('label')" [disabled]="disabled"></button>
<ng-container *ngTemplateOutlet="getTemplateRef('after')" />
}
`,
standalone: false
})
export class SaveButtonComponent extends FormFieldBaseComponent<undefined> {
public override logName: string = "SaveButtonComponent";
protected override formComponent: FormComponent = inject(FormComponent);
public override componentDefinition?: SaveButtonComponentDefinition;

protected override async setComponentReady(): Promise<void> {
await super.setComponentReady();
}

public async save() {
if (this.formComponent && !this.disabled) {
await this.formComponent.saveForm(this.componentDefinition?.config?.forceSave, this.componentDefinition?.config?.targetStep, this.componentDefinition?.config?.skipValidation);
} else {
this.loggerService.debug(`Save button clicked but form is not valid or dirty`);
}
}

get disabled(): boolean {
// Check if the `formComponent.formGroup` is valid and dirty
// Disable if the form is invalid or pristine (unchanged)
return !this.formComponent?.dataStatus.valid || this.formComponent?.dataStatus.pristine;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,6 @@ export class TabContentComponent extends FormFieldBaseComponent<undefined> {
@HostBinding('id') get hostId(): string {
return this.tab?.id + '-tab-content';
}

public get tabs(): TabComponentEntryDefinition[] {
return this.tabs;
}
}


Expand Down
133 changes: 110 additions & 23 deletions angular/projects/researchdatabox/form/src/app/form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import { Component, Inject, Input, ElementRef, signal, HostBinding, ViewChild, viewChild, ViewContainerRef, ComponentRef, inject, Signal, effect } from '@angular/core';
import { Component, Inject, ElementRef, signal, HostBinding, ViewChild, ViewContainerRef, inject, effect, model } from '@angular/core';
import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
import { FormGroup } from '@angular/forms';
import { isEmpty as _isEmpty, isString as _isString, isNull as _isNull, isUndefined as _isUndefined, set as _set, get as _get } from 'lodash-es';
import { ConfigService, LoggerService, TranslationService, BaseComponent, FormFieldCompMapEntry, UtilityService } from '@researchdatabox/portal-ng-common';
import { isEmpty as _isEmpty, isString as _isString, isNull as _isNull, isUndefined as _isUndefined, set as _set, get as _get, trim as _trim } from 'lodash-es';
import { ConfigService, LoggerService, TranslationService, BaseComponent, FormFieldCompMapEntry, UtilityService, RecordService, RecordActionResult } from '@researchdatabox/portal-ng-common';
import { FormStatus, FormConfig } from '@researchdatabox/sails-ng-common';
import {FormBaseWrapperComponent} from "./component/base-wrapper.component";
import { FormComponentsMap, FormService } from './form.service';
Expand Down Expand Up @@ -53,11 +53,19 @@ import { FormComponentsMap, FormService } from './form.service';
export class FormComponent extends BaseComponent {
private logName = "FormComponent";
appName: string;
@Input() oid:string;
@Input() recordType: string;
@Input() editMode: boolean;
@Input() formName: string;
@Input() downloadAndCreateOnInit: boolean = true;
oid = model<string>('');
// cache the previous oid to retrigger the load
currentOid: string = '';
recordType = model<string>('');
editMode = model<boolean>(true);
formName = model<string>('');
downloadAndCreateOnInit = model<boolean>(true);
// Convenience map of trimmed string params
trimmedParams = {
oid: this.utilityService.trimStringSignal(this.oid),
recordType: this.utilityService.trimStringSignal(this.recordType),
formName: this.utilityService.trimStringSignal(this.formName)
}
/**
* The FormGroup instance
*/
Expand All @@ -74,6 +82,9 @@ export class FormComponent extends BaseComponent {

@ViewChild('componentsContainer', { read: ViewContainerRef, static: false }) componentsContainer!: ViewContainerRef | undefined;

recordService = inject(RecordService);
saveResponse = signal<RecordActionResult | undefined>(undefined);

constructor(
@Inject(LoggerService) private loggerService: LoggerService,
@Inject(ConfigService) private configService: ConfigService,
Expand All @@ -83,29 +94,51 @@ export class FormComponent extends BaseComponent {
@Inject(UtilityService) protected utilityService: UtilityService
) {
super();
this.initDependencies = [this.translationService, this.configService, this.formService];
this.oid = elementRef.nativeElement.getAttribute('oid');
this.recordType = elementRef.nativeElement.getAttribute('recordType');
this.editMode = elementRef.nativeElement.getAttribute('editMode') === "true";
this.formName = elementRef.nativeElement.getAttribute('formName') || "";
this.appName = `Form::${this.recordType}::${this.formName} ${ this.oid ? ' - ' + this.oid : ''}`.trim();
this.loggerService.debug(`'${this.logName}' waiting for '${this.formName}' deps to init...`);
this.initDependencies = [this.translationService, this.configService, this.formService, this.recordService];
// Params can be injected via HTML if the app is used outside of Angular
if (_isEmpty(this.trimmedParams.oid())) {
this.oid.set(elementRef.nativeElement.getAttribute('oid'));
}
if (_isEmpty(this.trimmedParams.recordType())) {
this.recordType.set(elementRef.nativeElement.getAttribute('recordType'));
}
if (_isEmpty(this.trimmedParams.formName())) {
this.formName.set(elementRef.nativeElement.getAttribute('formName'));
}
// HTML attribute overrides the defaults on init, but not when injected via Angular
if (!_isEmpty(_trim(elementRef.nativeElement.getAttribute('editMode')))) {
this.editMode.set(elementRef.nativeElement.getAttribute('editMode') === 'true');
}
if (!_isEmpty(_trim(elementRef.nativeElement.getAttribute('downloadAndCreateOnInit')))) {
this.downloadAndCreateOnInit.set(elementRef.nativeElement.getAttribute('downloadAndCreateOnInit') === 'true');
}

this.appName = `Form::${this.trimmedParams.recordType()}::${this.trimmedParams.formName()} ${ this.trimmedParams.oid() ? ' - ' + this.trimmedParams.oid() : ''}`.trim();
this.loggerService.debug(`'${this.logName}' waiting for '${this.trimmedParams.formName()}' deps to init...`);

effect(() => {
if (this.componentsLoaded()) {
this.registerUpdateExpression();
}
});
// Set another effect for the OID update, will reinit if changed if the form has been on the 'READY' state
effect(async () => {
if (!_isEmpty(this.trimmedParams.oid()) && this.currentOid !== this.trimmedParams.oid() && this.status() == FormStatus.READY) {
this.status.set(FormStatus.INIT);
this.componentsLoaded.set(false);
await this.initComponent();
}
});
}

protected get getFormService(){
return this.formService;
}

protected async initComponent(): Promise<void> {
this.loggerService.debug(`${this.logName}: Loading form with OID: ${this.oid}, on edit mode:${this.editMode}, Record Type: ${this.recordType}, formName: ${this.formName}`);
this.loggerService.debug(`${this.logName}: Loading form with OID: ${this.trimmedParams.oid()}, on edit mode:${this.editMode()}, Record Type: ${this.trimmedParams.recordType()}, formName: ${this.trimmedParams.formName()}`);
try {
if (this.downloadAndCreateOnInit) {
if (this.downloadAndCreateOnInit()) {
await this.downloadAndCreateFormComponents();
} else {
this.loggerService.warn(`${this.logName}: downloadAndCreateOnInit is set to false. Form will not be loaded automatically. Call downloadAndCreateFormComponents() manually to load the form.`);
Expand All @@ -120,7 +153,7 @@ export class FormComponent extends BaseComponent {
public async downloadAndCreateFormComponents(formConfig?: FormConfig): Promise<void> {
if (!formConfig) {
this.loggerService.log(`${this.logName}: creating form definition by downloading config`);
this.formDefMap = await this.formService.downloadFormComponents(this.oid, this.recordType, this.editMode, this.formName, this.modulePaths);
this.formDefMap = await this.formService.downloadFormComponents(this.trimmedParams.oid(), this.trimmedParams.recordType(), this.editMode(), this.trimmedParams.formName(), this.modulePaths);
} else {
this.loggerService.log(`${this.logName}: creating form definition from provided config`);
this.formDefMap = await this.formService.createFormComponentsMap(formConfig);
Expand All @@ -141,6 +174,8 @@ export class FormComponent extends BaseComponent {
}
// Moved the creation of the FormGroup to after all components are created, allows for components that have custom management of their children components.
this.createFormGroup();
// set the cache that will trigger a form reinit when the OID is changed
this.currentOid = this.trimmedParams.oid();
// Set the status to READY if all components are loaded
this.status.set(FormStatus.READY);
this.componentsLoaded.set(true);
Expand Down Expand Up @@ -173,7 +208,7 @@ export class FormComponent extends BaseComponent {
}

protected async getAndApplyUpdatedDataModel(){
const dataModel = await this.formService.getModelData(this.oid, this.recordType);
const dataModel = await this.formService.getModelData(this.trimmedParams.oid(), this.trimmedParams.recordType());
this.form?.patchValue(dataModel);
}

Expand All @@ -196,15 +231,15 @@ export class FormComponent extends BaseComponent {
}

@HostBinding('class.edit-mode') get isEditMode() {
return this.editMode;
return this.editMode();
}

@HostBinding('class') get hostClasses(): string {
if (!this.formDefMap?.formConfig) {
return '';
}

const cssClasses = this.editMode ? this.formDefMap.formConfig.editCssClasses : this.formDefMap.formConfig.viewCssClasses;
const cssClasses = this.editMode() ? this.formDefMap.formConfig.editCssClasses : this.formDefMap.formConfig.viewCssClasses;

if (!cssClasses) {
return '';
Expand Down Expand Up @@ -253,8 +288,8 @@ export class FormComponent extends BaseComponent {

// If the component has children components, recursively get their debug info. This used to be hardcoded for specific component types, but now it is generic.
const component = formFieldCompMapEntry?.component as any;
if (!_isEmpty(component?.components)) {
componentResult.children = component?.components?.map((i: FormFieldCompMapEntry) => this.getComponentDebugInfo(i));
if (!_isEmpty(component?.formFieldCompMapEntries)) {
componentResult.children = component?.formFieldCompMapEntries?.map((i: FormFieldCompMapEntry) => this.getComponentDebugInfo(i));
}

if (componentEntry?.layout) {
Expand Down Expand Up @@ -287,6 +322,58 @@ export class FormComponent extends BaseComponent {
return foundComponentDef;
}

public async saveForm(forceSave: boolean = false, targetStep: string = '', skipValidation: boolean = false) {
// Check if the form is ready, defined, modified OR forceSave is set
// Status check will ensure saves requests will not overlap within the Angular Form app context
if (this.status() === FormStatus.READY && this.form && (this.form.dirty || forceSave)) {
if (this.form.valid || skipValidation) {
this.loggerService.info(`${this.logName}: Form valid flag: ${this.form.valid}, skipValidation: ${skipValidation}. Submitting...`);
// Here you can handle the form submission, e.g., send it to the server
this.loggerService.debug(`${this.logName}: Form value:`, this.form.value);
// set status to 'saving'
this.status.set(FormStatus.SAVING);
try {
let response: RecordActionResult;
if (_isEmpty(this.trimmedParams.oid())) {
response = await this.recordService.create(this.form.value, this.trimmedParams.recordType(), targetStep);
} else {
response = await this.recordService.update(this.trimmedParams.oid(), this.form.value, targetStep);
}
if (response?.success) {
this.loggerService.info(`${this.logName}: Form submitted successfully:`, response);
this.form.markAsPristine();
} else {
this.loggerService.warn(`${this.logName}: Form submission failed:`, response);
}
this.saveResponse.set(response);
} catch (error: unknown) {
this.loggerService.error(`${this.logName}: Error occurred while submitting form:`, error);
// Emit an response with the error message object as string
let errorMsg = 'Unknown error occurred';
if (error instanceof Error) {
errorMsg = error.message;
}
this.saveResponse.set({ success: false, oid: this.trimmedParams.oid(), message: errorMsg } as RecordActionResult);
}
// set back to ready when all processing is complete
this.status.set(FormStatus.READY);
} else {
this.loggerService.warn(`${this.logName}: Form is invalid. Cannot submit.`);
// Handle form errors, e.g., show a message to the user
}
} else {
this.loggerService.info(`${this.logName}: Form is not ready/defined, dirty or forceSave is false. No action taken.`);
}
}

// Expose the `form` status
public get dataStatus(): { valid: boolean; dirty: boolean, pristine: boolean } {
return {
valid: this.form?.valid || false,
dirty: this.form?.dirty || false,
pristine: this.form?.pristine || false,
};
}
}

type DebugInfo = {
Expand Down
2 changes: 2 additions & 0 deletions angular/projects/researchdatabox/form/src/app/form.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {GroupFieldComponent} from "./component/groupfield.component";
import {DefaultLayoutComponent} from "./component/default-layout.component";
import {FormBaseWrapperComponent} from "./component/base-wrapper.component";
import {FormBaseWrapperDirective} from "./component/base-wrapper.directive";
import { SaveButtonComponent } from './component/save-button.component';
import {TabComponent, TabContentComponent} from "./component/tab.component";
@NgModule({
declarations: [
Expand All @@ -43,6 +44,7 @@ import {TabComponent, TabContentComponent} from "./component/tab.component";
RepeatableElementLayoutComponent,
ValidationSummaryFieldComponent,
GroupFieldComponent,
SaveButtonComponent,
TabComponent,
TabContentComponent,
],
Expand Down
Loading