Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
95be885
Add frameworks/angular package skeleton
sonukapoor May 24, 2026
323204e
Add angular build target to @formisch/methods
sonukapoor May 24, 2026
699e976
Add @angular/core peer and dev dependency to @formisch/methods
sonukapoor May 24, 2026
02482d1
Update lockfile for @angular/core dev dependency
sonukapoor May 24, 2026
a587099
Add Angular FormStore, FieldStore, and FieldArrayStore types
sonukapoor May 24, 2026
c7521a9
Add injectForm with Signal-based FormStore
sonukapoor May 24, 2026
849d935
Add injectField with Signal-based FieldStore
sonukapoor May 24, 2026
8db0267
Add injectFieldArray with Signal-based FieldArrayStore
sonukapoor May 24, 2026
81062c3
Add FormischForm standalone component
sonukapoor May 24, 2026
dcfe62b
Add FormischField standalone component with TemplateRef content proje…
sonukapoor May 24, 2026
ec6bb08
Add FormischFieldArray standalone component
sonukapoor May 25, 2026
6038a83
Convert components to signal inputs and contentChild() query
sonukapoor May 25, 2026
7b3841a
Wire up src/index.ts with all public exports
sonukapoor May 25, 2026
15841fe
Add Angular playground with login, payment, and todos routes
sonukapoor May 25, 2026
418dc51
Ignore docs/superpowers directory
sonukapoor May 25, 2026
3cd1d89
Expand Angular playground and fix AOT compilation issues
sonukapoor May 29, 2026
f035c2d
Fix select value binding and unstyled button loading state reset
sonukapoor May 29, 2026
66580bc
Refactor FormischField and FormischFieldArray to reuse inject functions
sonukapoor May 30, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ backups
.netlify
.vercel

# Claude generated files
docs/superpowers

# Others
logs
*.log
Expand Down
7 changes: 7 additions & 0 deletions frameworks/angular/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

All notable changes to the library will be documented in this file.

## v0.0.1 (Month DD, YYYY)
Comment thread
sonukapoor marked this conversation as resolved.
Comment thread
sonukapoor marked this conversation as resolved.
Comment thread
sonukapoor marked this conversation as resolved.

- Initial release
28 changes: 28 additions & 0 deletions frameworks/angular/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import eslint from '@eslint/js';
import { commonRules, importPlugin, jsdoc } from '@formisch/eslint-config';
import pluginSecurity from 'eslint-plugin-security';
import tseslint from 'typescript-eslint';

export default tseslint.config(
{ ignores: ['eslint.config.ts', 'dist', 'coverage'] },
eslint.configs.recommended,
jsdoc.configs['flat/recommended'],
pluginSecurity.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
name: 'app/files-to-lint',
files: ['src/**/*.ts'],
extends: [importPlugin.flatConfigs.recommended],
plugins: { jsdoc },
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
Comment thread
sonukapoor marked this conversation as resolved.
},
},
rules: {
...commonRules,
},
}
);
71 changes: 71 additions & 0 deletions frameworks/angular/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"name": "@formisch/angular",
"description": "The lightweight, schema-first, and fully type-safe form library for Angular",
"version": "0.0.1",
"license": "MIT",
"author": "Fabian Hiller",
"homepage": "https://formisch.dev",
"repository": {
"type": "git",
"url": "https://github.com/open-circle/formisch"
},
"keywords": [
"angular",
"form",
"typescript",
"schema",
"validation"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"sideEffects": false,
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"test": "vitest run --typecheck --passWithNoTests",
Comment thread
sonukapoor marked this conversation as resolved.
"lint": "eslint \"src/**/*.ts*\" && tsc --noEmit",
"lint.fix": "eslint \"src/**/*.ts*\" --fix",
"format": "prettier --write ./src",
"format.check": "prettier --check ./src"
},
"devDependencies": {
"@angular/common": "^19.0.0",
"@angular/compiler": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@formisch/core": "workspace:*",
"@formisch/eslint-config": "workspace:*",
"@formisch/methods": "workspace:*",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.31.0",
"jsdom": "^26.1.0",
"tsdown": "^0.16.8",
"typescript": "~5.8.3",
"valibot": "^1.2.0",
"vitest": "^3.2.4",
"zone.js": "^0.15.0"
},
"peerDependencies": {
"@angular/core": ">=17.0.0",
"typescript": ">=5",
"valibot": "^1.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Component, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import * as v from 'valibot';
import { describe, beforeEach, expect, it } from 'vitest';
import { injectForm } from '../../functions/index.ts';
import { FormischField } from './FormischField.ts';

const Schema = v.object({ email: v.pipe(v.string(), v.email()) });

@Component({
standalone: true,
imports: [FormischField],
template: `
<formisch-field [of]="form" [path]="['email']">
<ng-template let-field>
<input [name]="field.props.name" data-testid="input" />
<span data-testid="errors">{{ field.errors() }}</span>
</ng-template>
</formisch-field>
`,
})
class TestHost {
form = injectForm({ schema: Schema });
}

describe('FormischField', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestHost],
providers: [provideExperimentalZonelessChangeDetection()],
});
});

it('renders the template with the field store', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const input = (fixture.nativeElement as HTMLElement).querySelector('[data-testid="input"]');
expect(input).not.toBeNull();
});

it('passes the field name prop to the template context', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const input = (fixture.nativeElement as HTMLElement).querySelector('[data-testid="input"]');
expect(input.getAttribute('name')).not.toBeNull();
});
});
57 changes: 57 additions & 0 deletions frameworks/angular/src/components/FormischField/FormischField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
Component,
contentChild,
input,
type InputSignal,
type Signal,
TemplateRef,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import {
type RequiredPath,
type Schema,
type ValidPath,
} from '@formisch/core/angular';
import type * as v from 'valibot';
import type { FieldStore, FormStore } from '../../types/index.ts';
import { injectField } from '../../functions/injectField/injectField.ts';

/**
* Headless field component that provides reactive field state via an Angular template.
* Uses ContentChild TemplateRef pattern to pass the FieldStore as $implicit context.
*
* @example
* ```html
* <formisch-field [of]="form" [path]="['email']">
* <ng-template let-field>
* <input [name]="field.props.name" [value]="field.input()"
* (focus)="field.props.onFocus($event)"
* (input)="field.props.onChange($event)"
* (blur)="field.props.onBlur($event)" />
* </ng-template>
* </formisch-field>
* ```
*/
@Component({
selector: 'formisch-field',
standalone: true,
imports: [NgTemplateOutlet],
template: `
@if (template()) {
<ng-container
[ngTemplateOutlet]="template()!"
[ngTemplateOutletContext]="{ $implicit: field }"
/>
}
`,
})
export class FormischField<
TSchema extends Schema = Schema,
TFieldPath extends RequiredPath = RequiredPath,
> {
readonly of: InputSignal<FormStore<TSchema>> = input.required<FormStore<TSchema>>();
readonly path: InputSignal<ValidPath<v.InferInput<TSchema>, TFieldPath>> = input.required<ValidPath<v.InferInput<TSchema>, TFieldPath>>();

protected readonly template: Signal<TemplateRef<unknown> | undefined> = contentChild(TemplateRef);
protected readonly field: FieldStore<TSchema, TFieldPath> = injectField(this.of, { path: this.path });
}
1 change: 1 addition & 0 deletions frameworks/angular/src/components/FormischField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FormischField.ts';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Component, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import * as v from 'valibot';
import { describe, beforeEach, expect, it } from 'vitest';
import { injectForm } from '../../functions/index.ts';
import { FormischFieldArray } from './FormischFieldArray.ts';

const Schema = v.object({
todos: v.array(v.object({ title: v.string() })),
});

@Component({
standalone: true,
imports: [FormischFieldArray],
template: `
<formisch-field-array [of]="form" [path]="['todos']">
<ng-template let-fieldArray>
<span data-testid="count">{{ fieldArray.items().length }}</span>
</ng-template>
</formisch-field-array>
`,
})
class TestHost {
form = injectForm({ schema: Schema });
}

describe('FormischFieldArray', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestHost],
providers: [provideExperimentalZonelessChangeDetection()],
});
});

it('renders the template with the field array store', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(span).not.toBeNull();
});

it('passes items signal with initial empty array', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(span.textContent.trim()).toBe('0');
Comment on lines +36 to +46

@coderabbitai coderabbitai Bot May 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run initial change detection before DOM assertions

Add fixture.detectChanges() right after TestBed.createComponent(TestHost) in both tests; await fixture.whenStable() doesn’t perform the initial template render, so the DOM assertions can be flaky/missing the expected text.

Suggested fix
   it('renders the template with the field array store', async () =&gt; {
     const fixture = TestBed.createComponent(TestHost);
+    fixture.detectChanges();
     await fixture.whenStable();
     const span = fixture.nativeElement.querySelector('[data-testid="count"]');
     expect(span).not.toBeNull();
   });

   it('passes items signal with initial empty array', async () =&gt; {
     const fixture = TestBed.createComponent(TestHost);
+    fixture.detectChanges();
     await fixture.whenStable();
     const span = fixture.nativeElement.querySelector('[data-testid="count"]');
     expect(span.textContent.trim()).toBe('0');
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(span).not.toBeNull();
});
it('passes items signal with initial empty array', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(span.textContent.trim()).toBe('0');
const fixture = TestBed.createComponent(TestHost);
fixture.detectChanges();
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(span).not.toBeNull();
});
it('passes items signal with initial empty array', async () => {
const fixture = TestBed.createComponent(TestHost);
fixture.detectChanges();
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(span.textContent.trim()).toBe('0');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@frameworks/angular/src/components/FormischFieldArray/FormischFieldArray.test.ts`
around lines 36 - 46, The tests creating TestHost (in
FormischFieldArray.test.ts) call TestBed.createComponent(TestHost) and await
fixture.whenStable() but never run initial change detection, causing flaky DOM
assertions; update both tests to call fixture.detectChanges() immediately after
TestBed.createComponent(TestHost) (before awaiting whenStable() or before
querying the DOM) so the template renders and the assertions on the span
(data-testid="count") and its textContent are deterministic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would first get the implementation right before looking to closely at the test

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabian-hiller, understood! Feel free to revisit the detectChanges() addition once the implementation is solid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - we've now fixed the implementation to use proper signal inputs with input.required() and contentChild() for AOT compatibility, replacing the @Input({ isSignal: true } as any) pattern. We'll revisit the tests once the implementation is solid.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
Component,
contentChild,
input,
type InputSignal,
type Signal,
TemplateRef,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import {
type RequiredPath,
type Schema,
type ValidArrayPath,
} from '@formisch/core/angular';
import type * as v from 'valibot';
import type { FieldArrayStore, FormStore } from '../../types/index.ts';
import { injectFieldArray } from '../../functions/injectFieldArray/injectFieldArray.ts';

/**
* Headless field array component that provides reactive field array state via an Angular template.
* Uses ContentChild TemplateRef pattern to pass the FieldArrayStore as $implicit context.
*
* @example
* ```html
* <formisch-field-array [of]="form" [path]="['todos']">
* <ng-template let-fieldArray>
* @for (item of fieldArray.items(); track item) {
* ...
* }
* </ng-template>
* </formisch-field-array>
* ```
*/
@Component({
selector: 'formisch-field-array',
standalone: true,
imports: [NgTemplateOutlet],
template: `
@if (template()) {
<ng-container
[ngTemplateOutlet]="template()!"
[ngTemplateOutletContext]="{ $implicit: fieldArray }"
/>
}
`,
})
export class FormischFieldArray<
TSchema extends Schema = Schema,
TFieldArrayPath extends RequiredPath = RequiredPath,
> {
readonly of: InputSignal<FormStore<TSchema>> = input.required<FormStore<TSchema>>();
readonly path: InputSignal<ValidArrayPath<v.InferInput<TSchema>, TFieldArrayPath>> = input.required<ValidArrayPath<v.InferInput<TSchema>, TFieldArrayPath>>();

protected readonly template: Signal<TemplateRef<unknown> | undefined> = contentChild(TemplateRef);
protected readonly fieldArray: FieldArrayStore<TSchema, TFieldArrayPath> = injectFieldArray(this.of, { path: this.path });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FormischFieldArray.ts';
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Component, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import * as v from 'valibot';
import { describe, beforeEach, expect, it, vi } from 'vitest';
import { injectForm } from '../../functions/index.ts';
import { FormischForm } from './FormischForm.ts';

const Schema = v.object({ email: v.pipe(v.string(), v.email()) });

@Component({
standalone: true,
imports: [FormischForm],
template: `
<formisch-form [of]="form" [submitFn]="handleSubmit">
<button type="submit">Submit</button>
</formisch-form>
`,
})
class TestHost {
form = injectForm({ schema: Schema });
handleSubmit = vi.fn();
}

describe('FormischForm', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestHost],
providers: [provideExperimentalZonelessChangeDetection()],
});
});

it('renders a native form element', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const form = (fixture.nativeElement as HTMLElement).querySelector('form');
expect(form).not.toBeNull();
});

it('sets novalidate on the form element', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const form = (fixture.nativeElement as HTMLElement).querySelector('form');
expect(form.hasAttribute('novalidate')).toBe(true);
});

it('registers the form element on the internal store', async () => {
const fixture = TestBed.createComponent(TestHost);
await fixture.whenStable();
const { INTERNAL } = await import('@formisch/core/angular');
const internalStore = fixture.componentInstance.form[INTERNAL];
expect(internalStore.element).toBeInstanceOf(HTMLFormElement);
});
});
Loading
Loading