-
-
Notifications
You must be signed in to change notification settings - Fork 31
Add @formisch/angular package with playground #112
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
Changes from all commits
95be885
323204e
699e976
02482d1
a587099
c7521a9
849d935
8db0267
81062c3
dcfe62b
ec6bb08
6038a83
7b3841a
15841fe
418dc51
3cd1d89
f035c2d
66580bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,9 @@ backups | |
| .netlify | ||
| .vercel | ||
|
|
||
| # Claude generated files | ||
| docs/superpowers | ||
|
|
||
| # Others | ||
| logs | ||
| *.log | ||
|
|
||
| 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) | ||
|
sonukapoor marked this conversation as resolved.
sonukapoor marked this conversation as resolved.
|
||
|
|
||
| - Initial release | ||
| 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, | ||
|
sonukapoor marked this conversation as resolved.
|
||
| }, | ||
| }, | ||
| rules: { | ||
| ...commonRules, | ||
| }, | ||
| } | ||
| ); | ||
| 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", | ||
|
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(); | ||
| }); | ||
| }); |
| 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 }); | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Run initial change detection before DOM assertions Add Suggested fix it('renders the template with the field array store', async () => {
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');
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.