diff --git a/projects/ppwcode/ng-forms/src/lib/signal-form-change-detection.spec.ts b/projects/ppwcode/ng-forms/src/lib/signal-form-change-detection.spec.ts new file mode 100644 index 00000000..e3f62dfe --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/signal-form-change-detection.spec.ts @@ -0,0 +1,72 @@ +import { signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { FieldTree, form, required } from '@angular/forms/signals' +import { detectFormChanges } from './signal-form-change-detection' + +interface TestFormModel { + unit: string + comment: string | null + nested: { + code: string | null + } +} + +describe('detectFormChanges', () => { + const createForm = (): FieldTree => + TestBed.runInInjectionContext(() => + form( + signal({ + unit: 'Piece', + comment: null, + nested: { + code: null + } + }), + (schema) => required(schema.unit) + ) + ) + + it('should start without changes', () => { + const testForm = createForm() + const tracker = detectFormChanges(testForm) + + expect(tracker.hasChanges()).toBe(false) + expect(tracker.invalidOrNoChanges()).toBe(true) + }) + + it('should detect changes and reset the current value as the new initial value', () => { + const testForm = createForm() + const tracker = detectFormChanges(testForm) + + testForm.unit().value.set('Box') + + expect(tracker.hasChanges()).toBe(true) + expect(tracker.invalidOrNoChanges()).toBe(false) + + tracker.resetChangeTracking() + + expect(tracker.hasChanges()).toBe(false) + expect(tracker.invalidOrNoChanges()).toBe(true) + }) + + it('should ignore empty strings and null values when comparing form values', () => { + const testForm = createForm() + const tracker = detectFormChanges(testForm) + + testForm.comment().value.set('') + testForm.nested.code().value.set('') + + expect(tracker.hasChanges()).toBe(false) + }) + + it('should report invalid or unchanged when the form is invalid after a change', () => { + const testForm = createForm() + const tracker = detectFormChanges(testForm) + + testForm.unit().value.set('') + + expect(testForm().invalid()).toBe(true) + expect(tracker.hasChanges()).toBe(true) + expect(tracker.invalidOrNoChanges()).toBe(true) + }) +}) diff --git a/projects/ppwcode/ng-forms/src/lib/signal-form-change-detection.ts b/projects/ppwcode/ng-forms/src/lib/signal-form-change-detection.ts new file mode 100644 index 00000000..fd59f9e1 --- /dev/null +++ b/projects/ppwcode/ng-forms/src/lib/signal-form-change-detection.ts @@ -0,0 +1,68 @@ +import { computed, Signal, signal } from '@angular/core' +import { FieldTree } from '@angular/forms/signals' + +type Valuable = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] } + +/** + * Interface describing the returned structure of `detectFormChanges`. + */ +export interface FormTracker { + // Signal indicating whether the tracked form has changes when compared to its initial value. + hasChanges: Signal + // Signal indicating whether the tracked form is invalid or has no changes when compared to its initial value. + invalidOrNoChanges: Signal + // Memorizes the current value as the new initial value of the form to verify whether changes are made against the current value. + resetChangeTracking: () => void +} + +/** + * Tracks changes on the given Signal form FieldTree. + * @example + * ```ts + * @Component({...}) + * export class AuthenticationForm { + * protected readonly form = form(signal({ username: '', password: ''})) + * protected readonly formTracker = detectFormChanges(this.form) + * } + * ``` + * @param fieldTree + * @returns FormTracker An object providing information about the change tracking. + */ +export const detectFormChanges = (fieldTree: FieldTree): FormTracker => { + // Function that will clean the given value and stringify it for easy comparison. + // This is backwards compatible with how we detected form changes on AbstractControl forms. + const stringifyValue = (value: T) => JSON.stringify(getValuable(value)) + + // Keep track of the initial value for detecting changes. + const initialValue = signal(fieldTree().value() as T) + const initialStringifiedValue = computed(() => stringifyValue(initialValue())) + + // Computes whether the new FieldTree value is different from the initial value. Note that the initial value is the value + // of the FieldTree when this function was invoked, or after calling resetChangeTracking. This means that it is not specifically + // the value of the signal the FieldTree was created with on initialization. + const hasChanges = computed(() => stringifyValue(fieldTree().value()) !== initialStringifiedValue()) + + // Computes whether the FieldTree is currently invalid or has no pending changes. This can be useful for situations + // where developers want to conditionally enable elements based on the form state. + const invalidOrNoChanges = computed(() => fieldTree().invalid() || !hasChanges()) + + return { + hasChanges, + invalidOrNoChanges, + resetChangeTracking: () => initialValue.set(fieldTree().value()) + } +} + +const getValuable = >(obj: T): V => + Object.fromEntries( + Object.entries(obj) + .map(([key, value]) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + // Recursively clean nested objects + const valuable: V | undefined = Object.keys(value).length ? getValuable(value) : undefined + return [key, valuable] + } + return [key, value] + }) + .filter(([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined')) + ) as V