diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/.editorconfig b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/.editorconfig new file mode 100644 index 000000000..f166060da --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/.gitignore b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/.gitignore new file mode 100644 index 000000000..b1d225e26 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/.gitignore @@ -0,0 +1,43 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/README.md b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/README.md new file mode 100644 index 000000000..fbe8f91d4 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/README.md @@ -0,0 +1,59 @@ +# Movies + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.22. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/angular.json b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/angular.json new file mode 100644 index 000000000..0b1381890 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/angular.json @@ -0,0 +1,93 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "movies": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css", + "node_modules/bootstrap/dist/css/bootstrap.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "movies:build:production" + }, + "development": { + "buildTarget": "movies:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + } + } + } + } + } +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/package.json b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/package.json new file mode 100644 index 000000000..8340da521 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/package.json @@ -0,0 +1,50 @@ +{ + "name": "movies", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "prettier": { + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] + }, + "private": true, + "dependencies": { + "@angular/common": "^20.3.0", + "@angular/compiler": "^20.3.0", + "@angular/core": "^20.3.0", + "@angular/forms": "^20.3.0", + "@angular/platform-browser": "^20.3.0", + "@angular/router": "^20.3.0", + "angular-in-memory-web-api": "^0.20.0", + "bootstrap": "^5.3.3", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular/build": "^20.0.6", + "@angular/cli": "^20.3.22", + "@angular/compiler-cli": "^20.3.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.9.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.9.2" + } +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/public/favicon.ico b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/public/favicon.ico differ diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app-data.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app-data.ts new file mode 100644 index 000000000..022ccdd7c --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app-data.ts @@ -0,0 +1,13 @@ +import { InMemoryDbService } from 'angular-in-memory-web-api'; +import { Movie } from './movies/movie'; +import { Review } from './reviews/review'; +import { MovieData } from './movies/movie-data'; +import { ReviewData } from './reviews/review-data'; + +export class AppData implements InMemoryDbService { + createDb(): { movies: Movie[]; reviews: Review[] } { + const movies = MovieData.movies; + const reviews = ReviewData.reviews; + return { movies, reviews }; + } +} \ No newline at end of file diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.config.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.config.ts new file mode 100644 index 000000000..daab26010 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.config.ts @@ -0,0 +1,20 @@ +import { + ApplicationConfig, + importProvidersFrom, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, +} from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { InMemoryWebApiModule } from 'angular-in-memory-web-api'; +import { AppData } from './app-data'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + importProvidersFrom(InMemoryWebApiModule.forRoot(AppData, { delay: 1000 })), + provideRouter(routes), + ], +}; diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.css b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.css new file mode 100644 index 000000000..eaabffe2c --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.css @@ -0,0 +1,12 @@ +.nav-link { + font-size: large; + cursor: pointer; +} + +.navbar-light .navbar-nav .nav-link.active { + color: #007acc; +} + +.navbar-brand { + margin-left: 10px; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.html b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.html new file mode 100644 index 000000000..78469eda8 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.html @@ -0,0 +1,19 @@ + + +
+ +
diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.routes.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.routes.ts new file mode 100644 index 000000000..0ab0f1de6 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; +import { HomeComponent } from './home/home/home.component'; + +export const routes: Routes = [ + { path: 'welcome', component: HomeComponent }, + { + path: 'movies', + loadComponent: () => + import('./movies/movie-list/movie-list.component').then((m) => m.MovieListComponent), + }, +]; diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.spec.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.spec.ts new file mode 100644 index 000000000..6290f89bc --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, movies'); + }); +}); diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.ts new file mode 100644 index 000000000..e4b2ea68f --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/app.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLinkActive, RouterLink], + templateUrl: './app.html', + styleUrl: './app.css', +}) +export class App { + pageTitle = 'movies'; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/home/home/home.component.html b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/home/home/home.component.html new file mode 100644 index 000000000..9631a1486 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/home/home/home.component.html @@ -0,0 +1,16 @@ +
+
+ {{ pageTitle }} +
+
+
+
+ +
+
+
+
diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/home/home/home.component.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/home/home/home.component.ts new file mode 100644 index 000000000..80ded51ad --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/home/home/home.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [], + templateUrl: './home.component.html', + styles: ``, +}) +export class HomeComponent { + public pageTitle = 'Welcome'; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-data.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-data.ts new file mode 100644 index 000000000..4f42f89cc --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-data.ts @@ -0,0 +1,51 @@ +import { Movie } from './movie'; + +export class MovieData { + static movies: Movie[] = [ + { + id: 1, + movieName: 'Leaf Rake', + movieCode: 'GDN-0011', + description: 'Leaf rake with 48-inch wooden handle', + price: 19.95, + likes: 15, + hasReviews: true, + }, + { + id: 2, + movieName: 'Garden Adventure', + movieCode: 'GDN-0023', + description: '15 gallon capacity rolling garden cart', + price: 32.99, + likes: 2, + hasReviews: true, + }, + { + id: 5, + movieName: 'Hammer Arena', + movieCode: 'TBX-0048', + description: 'Curved claw steel hammer', + price: 8.9, + likes: 8, + hasReviews: true, + }, + { + id: 8, + movieName: 'Saw', + movieCode: 'TBX-0022', + description: '15-inch steel blade hand saw', + price: 11.55, + likes: 6, + hasReviews: false, + }, + { + id: 10, + movieName: 'Playing Video Games', + movieCode: 'GMG-0042', + description: 'A film about video games', + price: 35.95, + likes: 12, + hasReviews: true, + }, + ]; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-detail/movie-detail.component.html b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-detail/movie-detail.component.html new file mode 100644 index 000000000..cf90d352b --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-detail/movie-detail.component.html @@ -0,0 +1,63 @@ +
+
+ {{ pageTitle }} +
+ +
+
+
Name:
+
{{ movie.movieName }}
+
+ +
+
+
+
Code:
+
{{ movie.movieCode }}
+
+
+
Description:
+
{{ movie.description }}
+
+
+
Price:
+
+ {{ movie.price | currency : "USD" : "symbol" }} +
+
+
+
Likes:
+
{{ movie.likes ?? 0 }}
+
+ +
+ + + + + + + + + + + + + + + +
ReviewUsernameText
{{ review.title }}{{ review.userName }}{{ review.text }}
+ + No reviews for this movie +
+
+
+ +
+
Movie Detail
+
+
+ {{ errorMessage }} +
diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-detail/movie-detail.component.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-detail/movie-detail.component.ts new file mode 100644 index 000000000..1940a9429 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-detail/movie-detail.component.ts @@ -0,0 +1,25 @@ +import { CurrencyPipe, NgFor, NgIf } from '@angular/common'; +import { Component, Input, OnInit } from '@angular/core'; +import { Movie } from '../movie'; + +@Component({ + selector: 'app-movie-detail', + standalone: true, + imports: [NgIf, NgFor, CurrencyPipe], + templateUrl: './movie-detail.component.html', + styles: `` +}) +export class MovieDetailComponent { + // Just enough here for the template to compile + @Input() movieId: number = 0; + errorMessage = ''; + + // Movie to display + movie: Movie | null = null; + + // Set the page title + pageTitle = this.movie ? `Product Detail for: ${this.movie.movieName}` : 'Movie Detail'; + + addToCart(movie: Movie) { + } +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-list/movie-list.component.html b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-list/movie-list.component.html new file mode 100644 index 000000000..573189e6e --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-list/movie-list.component.html @@ -0,0 +1,30 @@ +
+
+
+
+ {{ pageTitle }} +
+ +
+
+ +
+
+
+
+ {{ errorMessage }} +
+
+ +
+ +
+
diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-list/movie-list.component.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-list/movie-list.component.ts new file mode 100644 index 000000000..99d4a991c --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie-list/movie-list.component.ts @@ -0,0 +1,27 @@ +import { NgClass, NgFor, NgIf } from '@angular/common'; +import { Component } from '@angular/core'; +import { MovieDetailComponent } from '../movie-detail/movie-detail.component'; +import { Movie } from '../movie'; + +@Component({ + selector: 'app-movie-list', + standalone: true, + imports: [NgIf, NgClass, NgFor, MovieDetailComponent], + templateUrl: './movie-list.component.html', + styles: ``, +}) +export class MovieListComponent { + // Just enough here for the template to compile + pageTitle = 'Movies'; + errorMessage = ''; + + // Movies + movies: Movie[] = []; + + // Selected movie id to highlight the entry + selectedMovieId: number = 0; + + onSelected(movieId: number): void { + this.selectedMovieId = movieId; + } +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie.service.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie.service.ts new file mode 100644 index 000000000..fa914df41 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class MovieService { + private moviesUrl = 'api/movies'; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie.ts new file mode 100644 index 000000000..d2dca1299 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/movies/movie.ts @@ -0,0 +1,13 @@ +import { Review } from '../reviews/review'; + +/* Defines the product entity */ +export interface Movie { + id: number; + movieName: string; + movieCode: string; + description: string; + price: number; + likes?: number; + hasReviews?: boolean; + reviews?: Review[]; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review-data.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review-data.ts new file mode 100644 index 000000000..90d2d2652 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review-data.ts @@ -0,0 +1,77 @@ +import { Review } from './review'; + +export class ReviewData { + static reviews: Review[] = [ + { + id: 1, + movieId: 10, + userName: 'jackharkness', + title: 'Really good', + text: "I've beat every level faster with this controller", + }, + { + id: 2, + movieId: 5, + userName: 'thor364', + title: 'Not great...', + text: 'I summon this film, and it does not heed my call', + }, + + { + id: 3, + movieId: 5, + userName: 'allthumbs', + title: 'Dangerous!', + text: 'I almost injured myself with this....', + }, + { + id: 4, + movieId: 2, + userName: 'mom42', + title: 'Great for the kiddos', + text: 'My kids love it!!', + }, + { + id: 5, + movieId: 5, + userName: 'theoden', + title: 'Now for wrath. Now for ruin', + text: 'This film (and a dinner bell) worked even better than a horn for drawing attention', + }, + { + id: 6, + movieId: 5, + userName: 'glamdring', + title: 'This was no foe-hammer', + text: 'Movie was much shorter than expected', + }, + { + id: 7, + movieId: 10, + userName: 'grima', + title: 'Nothing but a herald of woe', + text: 'I played no better with this controller than my old one', + }, + { + id: 8, + movieId: 1, + userName: 'hama', + title: 'Has no evil purpose', + text: 'This film is worthy of honor', + }, + { + id: 9, + movieId: 1, + userName: 'hama', + title: 'More than a movie', + text: 'The movie makes me cry...', + }, + { + id: 10, + movieId: 1, + userName: 'eowyn', + title: 'A necessity!', + text: 'Those without expectations can still die upon them', + }, + ]; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review.service.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review.service.ts new file mode 100644 index 000000000..521e6ab22 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class ReviewService { + private reviewUrl = 'api/reviews'; + + getReviewUrl(movieId: number): string { + return `${this.reviewUrl}?movieId=^${movieId}$`; + } +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review.ts new file mode 100644 index 000000000..c56e6c26a --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/reviews/review.ts @@ -0,0 +1,8 @@ +/* Defines a product review */ +export interface Review { + id: number; + movieId: number; + userName: string; + title: string; + text: string; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/utilities/http-error.service.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/utilities/http-error.service.ts new file mode 100644 index 000000000..83b97b3c7 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/utilities/http-error.service.ts @@ -0,0 +1,26 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class HttpErrorService { + formatError(err: HttpErrorResponse): string { + return this.httpErrorFormatter(err); + } + + private httpErrorFormatter(err: HttpErrorResponse): string { + // In a real world app, we may send the error to some remote logging infrastructure + // instead of just logging it to the console + // console.error(err); + let errorMessage = ''; + if (err.error instanceof ErrorEvent) { + // A client-side or network error occurred. Handle it accordingly. + errorMessage = `An error occurred: ${err.error.message}`; + } else { + // The backend returned an unsuccessful response code. + errorMessage = `Server returned code: ${err.status}, error message is: ${err.statusText}`; + } + return errorMessage; + } +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/utilities/page-not-found.component.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/utilities/page-not-found.component.ts new file mode 100644 index 000000000..147d1429b --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/app/utilities/page-not-found.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-page-not-found', + standalone: true, + imports: [], + template: `

This is not the page you were looking for!

`, + styles: `` +}) +export class PageNotFoundComponent { + +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/index.html b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/index.html new file mode 100644 index 000000000..290d61b73 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/index.html @@ -0,0 +1,13 @@ + + + + + Movies + + + + + + + + diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/main.ts b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/main.ts new file mode 100644 index 000000000..5df75f9c8 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/styles.css b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/styles.css new file mode 100644 index 000000000..78be18673 --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/src/styles.css @@ -0,0 +1,9 @@ +/* You can add global styles to this file, and also import other style files */ +div.card-header { + font-size: large; +} + +div.card { + margin-top: 20px; + margin-left: 10px; +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.app.json b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.app.json new file mode 100644 index 000000000..264f459bf --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.json b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.json new file mode 100644 index 000000000..e4955f26b --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.json @@ -0,0 +1,34 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.spec.json b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.spec.json new file mode 100644 index 000000000..940b30a5f --- /dev/null +++ b/04-frameworks/02-angular/11-rxjs/00-start-code-20/movies/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/04-frameworks/02-angular/version-20/02-list-users/readme_es.md b/04-frameworks/02-angular/version-20/02-list-users/readme_es.md new file mode 100644 index 000000000..a41f7d790 --- /dev/null +++ b/04-frameworks/02-angular/version-20/02-list-users/readme_es.md @@ -0,0 +1,247 @@ +# 01 Listado de usuarios + +## Resumen + +Este ejemplo toma como punto de partida una aplicación recién creada. + +Vamos a pedirle a la API de Github la lista de miembros que pertenece a una +organización y vamos a mostrarla por pantalla. + +Que vamos a aprender en este ejemplo: + +- Cómo crear un componente de visualización +- Cómo hacer una llámada asícnrona para pedir datos a una api remota. +- Cómo iterar y mostrar una lista de resultados con la directiva estructural `*ngFor` + +También aprenderemos posteriormente la librería de Angular para realizar peticiones a APIs remotas. + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Si queremos ver que tipo de datos vamos a manejar, podemos abrir el navegador web y ver que devuelve la API Rest de Github + +```bash +https://api.github.com/orgs/lemoncode/members +``` + +- Creamos nuestro modelo + +Creamos un directorio _model_ dentro de _src/app_ y en dicho directorio creamos un fichero _model.ts_ con el interfaz de nuestro modelo: + +_./src/app/model/index.ts_ + +```typescript +export interface MemberEntity { + avatar_url: string; + id: string; + login: string; +} +``` + +- Utilizamos el CLI de Angular para crear el componente + +```bash +ng g c user/user-list +``` + +- Incluimos el componente en la página de nuestra aplicación + +Abrimos el fichero .ts del componente recién creado (_src/app/user/user-list/user-list.component.ts_) y nos fijamos en cuál es su selector. (_app-user-list_). + +Abrimos el fichero app.component.html y ponemos el tag donde queramos que aparezca nuestro componente. + +_src/app/app.component.ts_ + +```diff +@Component({ + selector: 'app-root', + standalone: true, +- imports: [CommonModule, RouterOutlet, MenuComponent, SearchComponent], + imports: [ + CommonModule, + RouterOutlet, + MenuComponent, + SearchComponent, ++ UserListComponent, + ], + templateUrl: './app.component.html', + styleUrl: './app.component.css', +}) +``` + +_src/app/app.component.html_ + +```diff +

Hola Mundo

+ + + +- + ++ +``` + +Y comprobamos en el navegador que el componente aparece en la pantalla. + +- HTML de partida + +Creamos el HTML necesario para mostrar una lista de usuarios. El siguiente código podría valer como ejemplo: + +_src/app/user/user-list/user-list.component.html_ + +```html + + + + + + + + + + + + + + + +
AvatarIdName
+ + + xxxxxxxx + + xxxxxxxx +
+``` + +- Obtener la lista de miembros + +El HTML necesita los datos de los miembros. + +Recordemos que en Angular la plantilla (html) de un componente tiene acceso a las propiedades públicas y métodos públicos de SU clase componente (ts). + +Por lo tanto la misión del ts será conseguir la lista de miembros y guardarla en una propiedad pública de su clase. + +_src/app/user/user-list/user-list.component.ts_ + +```ts +import { Component, OnInit } from '@angular/core'; +import { MemberEntity } from '../../model'; + +@Component({ + selector: 'app-user-list', + standalone: true, + imports: [], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + + ngOnInit(): void { + fetch('https://api.github.com/orgs/lemoncode/members') + .then((response) => response.json()) + .then((result) => (this.members = result)); + } +} + +``` + +- Iterar la lista en el html para mostrar los datos. + +Con la directiva estructural *ngFor recorremos el listado de miembros + +_src/app/user/user-list/user-list.component.html_ + +```diff + + + + + + + + + +- +- +- +- +- ++ @for (member of members; track member) { ++ ++ ++ ++ ++ + } + +
AvatarIdName
+- +- +- xxxxxxxx +- +- xxxxxxxx +-
++ ++ ++ {{member.id}} ++ ++ {{member.login}} ++
+``` + +Añadimos estilos para visualizar mejor los elementos de la tabl: + +_src/app/user/user-list/user-list.component.css_ + +```css +/* Estilos generales para la tabla */ +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; /* Ajusta el margen según sea necesario */ +} + +/* Estilos para las celdas del encabezado */ +th { + background-color: #f2f2f2; /* Color de fondo del encabezado */ + padding: 10px; + text-align: left; +} + +/* Estilos para las celdas del cuerpo de la tabla */ +td { + padding: 10px; + border-bottom: 1px solid #ddd; /* Línea divisoria entre filas */ +} + +/* Estilos para las imágenes dentro de las celdas */ +img { + max-width: 50px; /* Ajusta el tamaño máximo de las imágenes */ + border-radius: 50%; /* Borde redondeado para las imágenes */ +} + +/* Estilos para las filas impares, para mejorar la legibilidad */ +tbody tr:nth-child(odd) { + background-color: #f9f9f9; /* Fondo alternativo para filas impares */ +} + +``` + +Y ya está: _members_ empieza siendo una lista vacía [] y en el html se reflejará por lo tanto una tabla sin filas, pero en cuanto la petición a la API devuelva datos y los guardemos en la propiedad members del ts, Angular actualizará el HTML y veremos los datos en la pantalla. diff --git a/04-frameworks/02-angular/version-20/03-directives/readme_es.md b/04-frameworks/02-angular/version-20/03-directives/readme_es.md new file mode 100644 index 000000000..9a12d5b04 --- /dev/null +++ b/04-frameworks/02-angular/version-20/03-directives/readme_es.md @@ -0,0 +1,542 @@ +# 02 Directiva de atributo + +## Resumen + +Este ejemplo toma como punto de partida el ejemplo 01. + +Vamos a crear una directiva que **resalte elementos HTML** al pasar el ratón por encima del elemento. La directiva nos permitirá elegir el color de resaltado. + +Qué vamos a aprender en este ejemplo: + +- Cómo crear una directiva personalizada +- Cómo parametrizar una directiva + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Utilizamos el CLI de Angular para crear la directiva + +```bash +ng g d directives/highlight +``` + +Este es el archivo geneado por el CLI + +_src/app/directives/highlight.ts_ + +```ts +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true +}) +export class HighlightDirective { + + constructor() { } + +} +``` + +Como se puede apreciar, una directiva es una clase decorada con la función decoradora `@Directive`. + +El objeto de metadatos de este decorador es simple, un objeto con una propiedad _selector_ en el que, con sintaxis de selectores css, configuramos qué elementos van a verse afectados por esta directiva. + +Con el decorador del ejemplo anterior le decimos a Angular que debe aplicar esta directiva a todos los elementos del DOM que tengan el ATRIBUTO _appHighlight_. + +- Ponemos la directiva en los elementos html en los que queramos que se aplique. Voy a poner la directiva en un elemento del listado de usuarios y en otro elemento del menú. + +_src/app/user/user-list/user-list.component.ts_ + +```diff ++import { Highlight } from '../../directives/highlight'; + +@Component({ + selector: 'app-user-list', + standalone: true, + imports: [ ++ Highlight + ], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +``` + +_src/app/user/user-list/user-list.component.html_ + +```diff + + + +- ++ + + + + + + @for (member of members; track member) { + + + + + + } + +
AvatarAvatarIdName
+ + + {{member.id}} + + {{member.login}} +
+``` + +_src/app/layout/menu/menu.component.ts_ + +```diff +import { Component } from '@angular/core'; ++import { Highlight } from '../../directives/highlight'; + +@Component({ + selector: 'app-menu', + standalone: true, +- imports: [], ++ imports: [ ++ Highlight ++ ], + templateUrl: './menu.component.html', + styleUrl: './menu.component.css' +}) +export class MenuComponent { + +} +``` + +_src/app/layout/menu/menu.component.html_ + +```diff +-

menu works!

++

menu works!

+``` + +Angular aplicará la directiva tanto al `th` del listado de usuarios como al `p` del menú. + +- Programamos la directiva + +La potencia que tienen las directivas de atributo es que Angular les puede pasar como argumento del constructor una _referencia_ al elemento sobre el que se está aplicando. + +_src/app/directives/highlight.ts_ + +```diff +- import { Directive } from '@angular/core'; ++ import { Directive, ElementRef } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true +}) +export class HighlightDirective { + +- constructor() { } ++ constructor(el: ElementRef) { } + +} +``` + +De esta forma, tendremos en la variable _el_ una referencia al elemento th en el caso del listado de usuarios y una referencia al elemento p en el caso del menú. + +`ElementRef` tiene una única propiedad, _nativeElement_, que nos da acceso al elemento en sí. A través de _nativeElement_ podemos acceder y modificar cualquier atributo de elemento en cuestión. + +Vamos a modificar el valor de `style.backgroundColor`: + +_src/app/directives/highlight.ts_ + +```diff +import { Directive, ElementRef } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class Highlight { + +- constructor(el: ElementRef) { } ++ constructor(el: ElementRef) { ++ (el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; ++ } + +} +``` + +Si miramos el navegador veremos que Angular a cambiado el valor de `style.backgroundColor` a todos los elementos que tengan el atributo _appHighlight_. + +- Modificar la directiva para que solamente se resalte cuando pongamos el ratón encima del elemento + +Vamos a reescribir la directiva para que los elementos no estén resaltados de inicio, sino que se resalten cuando el ratón se ponga encima de ellos. + +Para ello, desde la directiva vamos a escuchar el evento _mouseenter_ de los elementos afectados por la directiva. + +Esto lo hacemos con el decorador **@HostListener**. Este decorador configura un método para ser el _listener_ de un evento ocurrido en el elemento del DOM que haga de anfitrión (_host_) de la directiva. + +```diff +- import { Directive, ElementRef } from '@angular/core'; ++ import { Directive, ElementRef, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class HighlightDirective { + +- constructor(el: ElementRef) { ++ constructor(public el: ElementRef) { +- (el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; + } + ++ @HostListener('mouseenter') ++ onMouseEnterEvent() { ++ (this.el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; ++ } + +} +``` + +Y para que desde `onMouseEnterEvent()` podamos acceder a la referencia (_el_) que llega a través del constructor, almacenamos la variable _el_ en una propiedad de la clase. + +```diff +import { Directive, ElementRef, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class HighlightDirective { + ++ private el: ElementRef; + constructor(el: ElementRef) { ++ this.el = el; + } + + @HostListener('mouseenter') + onMouseEnterEvent() { +- el.nativeElement.style.backgroundColor = 'lightblue'; ++ (this.el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; + } + +} +``` + +Aprovechándonos de los **constructores breves** que nos aporta el lenguaje typescript, podemos ahorrarnos un par de líneas en el código si indicamos la visibilidad (public/private) en el propio argumento del constructor + +```diff +import { Directive, ElementRef, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class Highlight { + +- private el: ElementRef; +- constructor(el: ElementRef) { +- this.el = el; +- } + ++ constructor(private el: ElementRef) { } + + @HostListener('mouseenter') + onMouseEnterEvent() { + (this.el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; + } + +} +``` + +Podemos comprobar que los elementos se resaltan cuando el ratón se ponga encima del elemento. + +- Modificar la directiva para que solamente se resalte cuando pongamos el ratón encima del elemento + +De forma análoga podemos programar que el elemento deje de estar resaltado cuando el ratón salga del área ocupada por el elemento. + +```diff +import { Directive, ElementRef, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class Highlight { + + constructor(private el: ElementRef) { } + + @HostListener('mouseenter') + onMouseEnterEvent() { + (this.el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; + } ++ ++ @HostListener('mouseleave') ++ onMouseLeaveEvent() { ++ this.el.nativeElement.style.backgroundColor = ''; ++ } ++ +} +``` + +- Refactorización + +Quitamos algo de código repetido y nos llevamos ese _magic value_ que es 'lightblue' a un lugar que sea más accesible y rápido de identificar. + + +```diff +import { Directive, ElementRef, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class Highlight { + ++ color = 'lightblue'; + constructor(private el: ElementRef) { } + ++ private highlight(color: string) { ++ (this.el.nativeElement as HTMLElement).style.backgroundColor = color; ++ } + + @HostListener('mouseenter') + onMouseEnterEvent() { +- (this.el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; ++ this.highlight(this.color); + } + + @HostListener('mouseleave') + onMouseLeaveEvent() { +- (this.el.nativeElement as HTMLElement).style.backgroundColor = ''; ++ this.highlight(''); + } + +} +``` + +- Aplicamos el decorador @Input para parametrizar el color de resaltado + +Con otro decorador llamado @Input podemos configurar una propiedad de nuestra clase HighlightDirective para que su valor pueda ser establecido desde fuera. + +```diff +- import { Directive, ElementRef, HostListener } from '@angular/core'; ++ import { Directive, ElementRef, HostListener, Input } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]' +}) +export class Highlight { + +- color = 'lightblue'; ++ @Input() ++ public color!: string; + + constructor(private el: ElementRef) { } + + private highlight(color: string) { + (this.el.nativeElement as HTMLElement).style.backgroundColor = color; + } + + @HostListener('mouseenter') + onMouseEnterEvent() { + this.highlight(this.color); + } + + @HostListener('mouseleave') + onMouseLeaveEvent() { + this.highlight(''); + } + +} +``` + +Este decorador `@Input` hace que los elementos afectados por `appHighlight` dispongan de un nuevo atributo, llamado _color_ (mismo nombre que la propiedad decorada con `@Input`). El valor del atributo color en el html se establecerá como valor de la propiedad en la directiva. + +_src/app/user/user-list/user-list.component.html_ + +```diff + + + +- ++ + + + + + + + + + + + +
AvatarAvatarIdName
+ + + {{ member.id }} + + {{ member.login }} +
+``` + +_src/app/layout/menu/menu.component.html_ + +```diff +-

menu works!

++

menu works!

+``` + +Turno de probarlo. + +- Ponemos un color por defecto + +Con un poco de javascript podemos elegir un color por defecto en caso de que no se haya usado el atributo color. + +```diff +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class HighlightDirective { + + @Input() + public color!: string; + ++ private defaultColor = 'lightblue'; + + constructor(private el: ElementRef) { } + + private highlight(color: string) { + (this.el.nativeElement as HTMLElement).style.backgroundColor = color; + } + + @HostListener('mouseenter') + onMouseEnterEvent() { +- this.highlight(this.color); ++ this.highlight(this.color || this.defaultColor); + } + + @HostListener('mouseleave') + onMouseLeaveEvent() { + this.highlight(''); + } + +} +``` + +Podemos tener tantas propiedades decoradas con `@Input()` como queramos. + +Cabe recordar que podemos utilizar binding en el html para asociar propiedades/atributos con propiedades del typescript del componente: + +_src/app/layout/menu/menu.component.html_ + +```diff +-

menu works!

++

menu works!

+``` + +_src/app/layout/menu/menu.component.ts_ + +```ts +import { Component } from '@angular/core'; +import { Highlight } from '../../directives/highlight.directive'; + +@Component({ + selector: 'app-menu', + standalone: true, + imports: [Highlight], + templateUrl: './menu.component.html', + styleUrl: './menu.component.css', +}) +export class MenuComponent { + /*diff*/ + public variableColorEnElTS = 'pink'; + /*diff*/ +} + +``` + +Así hacemos que sea el typescript del componente el que decida cuál es el color con el que la directiva debe resaltar el elemento. + +- Aprovechar el propio atributo de la directiva como `@Input` para el color. + +El decorador `@Input` permite realizar la asociación aunque los nombres del atributo html y de la propiedad de la directiva no coincidan. Por ejemplo: + +_src/app/layout/menu/menu.component.html_ + +```diff +-

menu works!

+-

menu works!

++

menu works!

+``` + +```diff +export class Highlight { + ++ @Input('colorResaltado') + public color!: string; +``` + +Esto se suele utilizar para aprovechar el propio atributo `appHighlight` para pasar uno de los parámetros que tengamos con @Input. + +_src/app/layout/menu/menu.component.html_ + +```diff +-

menu works!

++

menu works!

+``` + +```diff +import { Directive, ElementRef, HostListener, Input } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true, +}) +export class Highlight { + +- @Input() ++ @Input('appHighlight') + public color; + + private defaultColor = 'lightblue'; + + constructor(private el: ElementRef) { } + + private highlight(color: string) { + this.el.nativeElement.style.backgroundColor = color; + } + + @HostListener('mouseenter') + onMouseEnterEvent() { + this.highlight(this.color || this.defaultColor); + } + + @HostListener('mouseleave') + onMouseLeaveEvent() { + this.highlight(''); + } + +} +``` diff --git a/04-frameworks/02-angular/version-20/04-template-forms/readme_es.md b/04-frameworks/02-angular/version-20/04-template-forms/readme_es.md new file mode 100644 index 000000000..18a9baef2 --- /dev/null +++ b/04-frameworks/02-angular/version-20/04-template-forms/readme_es.md @@ -0,0 +1,410 @@ +# 03 Template Forms + +## Resumen + +Este ejemplo toma como punto de partida el ejemplo 02. + +Vamos a crear un formulario para añadir miembros a la lista. + +Qué vamos a aprender en este ejemplo: + +- Formularios template-driven +- La directiva `ngModel` +- Directivas de atributo para validaciones +- Variables de plantilla +- Resaltar con CSS los campos no válidos +- Mostrar mensajes de error + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Añadimos html de formulario de alta + +_src/app/user/user-list/user-list.component.html_ + +```diff ++

Alta

++
++
++
++ ++ ++
++
++ ++ ++
++
++ ++ ++
++ ++
++
+ ++

Listado

++
+ + + + + + + + + + @for (member of members; track member) { + + + + + + } + +
AvatarIdName
+ + + {{member.id}} + + {{member.login}} +
++
+``` + +- Creamos una variable en el componente de tipo _MemberEntity_, cuyas propiedades bindearemos con los inputs del formulario, y la inicializamos para no tener errores por intentos de acceso a propiedades de objetos _null_. + +_src/app/user/user-list/user-list.component.ts_ + +```diff +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; ++ newMember!: MemberEntity; + + ngOnInit(): void { + fetch('https://api.github.com/orgs/lemoncode/members') + .then((response) => response.json()) + .then((result) => (this.members = result)); ++ ++ this.newMember = { ++ id: '', ++ login: '', ++ avatar_url: '', ++ }; + } +} +``` + +- Usamos la directiva `ngModel` para hacer doble binding con los inputs de id y de nombre, para ello importamos el módulo `FormsModule` + +_src/app/user/user-list/user-list.component.ts_ + +```diff +@Component({ + selector: 'app-user-list', + standalone: true, +- imports: [Highlight], ++ imports: [Highlight, FormsModule], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +``` + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Alta

+
+
+
+ +- ++ +
+
+ +- ++ +
+
+ + +
+ +
+
+``` + +- Hacemos binding del evento click del botón con un método en el componente que añada el nuevo miembro a la lista + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Alta

+
+
+
+ + +
+
+ + +
+
+ + +
+- ++ +
+
+``` + +_src/app/user/user-list/user-list.component.ts_ + +```ts +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; + /*diff*/ + add(): void { + this.members.push(this.newMember); + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + } + /*diff*/ + ngOnInit(): void { + fetch('https://api.github.com/orgs/lemoncode/members') + .then((response) => response.json()) + .then((result) => (this.members = result)); + + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + } +} +``` + +- Añadimos validaciones con directivas de atributo (`required`, `min`, `max`, `minlength`, `maxlength`, `email`, `pattern`...) + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Alta

+
+
+
+ +- ++ +
+
+ +- ++ +
+
+ + +
+ +
+
+``` + +- Aprovechamos las clases `ng-xxxxx` que pone `ngModel` a los input para dar estilos CSS según el estado de validez de los campos + +_src/app/user/user-list/user-list.component.css_ + +```css +.ng-valid[required], .ng-valid.required { + border-left: 5px solid #42A948; +} + +.ng-invalid:not(form) { + border-left: 5px solid #a94442; +} +``` + +- Utilizamos template reference variables (extendidas de la clase `ngModel`) para incluir mensajes de error + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Alta

+
+
+
+ +- ++ ++ @if (id.invalid && id.touched) { ++
El id es obligatorio
++ } +
+
+ +- ++ ++ @if(name.invalid && name.touched) { ++ @if(name.errors!['required']) { ++
El nombre es obligatorio
++ } ++ @if(name.errors!['minlength']) { ++
++ El nombre debe tener {{ name.errors!['minlength'].requiredLength }} caracteres mínimo. ++ Tiene solamente {{ name.errors!['minlength'].actualLength }} ++
++ } ++ } +
+
+ + +
+ +
+
+``` + +- Utilizamos otra variable de plantilla (esta vez extendida de la directiva `ngForm`) para deshabilitar el botón si algún campo no es válido: + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Alta

+
+-
++ +
+ + + @if (id.invalid && id.touched) { +
El id es obligatorio
+ } +
+
+ + + @if(name.invalid && name.touched) { + @if(name.errors!['required']) { +
El nombre es obligatorio
+ } + @if(name.errors!['minlength']) { +
+ El nombre debe tener {{ name.errors!['minlength'].requiredLength }} caracteres mínimo. + Tiene solamente {{ name.errors!['minlength'].actualLength }} +
+ } + } +
+
+ + +
+- ++ +
+
+``` + +- El contenido de la propiedad value de un input de tipo file no se puede bindear con la propiedad `avatar_url` porque en nuestro caso no contienen el mismo tipo de información. Gestionamos el input file "manualmente" con el evento _change_ y un poco de javascript en el componente. + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Alta

+
+
+
+ + + @if (id.invalid && id.touched) { +
El id es obligatorio
+ } +
+
+ + + @if(name.invalid && name.touched) { + @if(name.errors!['required']) { +
El nombre es obligatorio
+ } + @if(name.errors!['minlength']) { +
+ El nombre debe tener {{ name.errors!['minlength'].requiredLength }} caracteres mínimo. + Tiene solamente {{ name.errors!['minlength'].actualLength }} +
+ } + } +
+
+ +- ++ ++
+
+ +
+
+``` + + +_src/app/user/user-list/user-list.component.ts_ + +```diff +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; + + add(): void { + this.members.push(this.newMember); + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + } ++ ++ handleFileInput($event: any) { ++ const files = $event.target.files as FileList; ++ const reader = new FileReader(); ++ reader.readAsDataURL(files[0]); ++ reader.onload = () => { ++ this.newMember.avatar_url = reader.result as string; ++ }; ++ } ++ + ngOnInit(): void { + fetch('https://api.github.com/orgs/lemoncode/members') + .then((response) => response.json()) + .then((result) => (this.members = result)); + + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + } +} +``` diff --git a/04-frameworks/02-angular/version-20/05-pipes/readme_es.md b/04-frameworks/02-angular/version-20/05-pipes/readme_es.md new file mode 100644 index 000000000..006f38684 --- /dev/null +++ b/04-frameworks/02-angular/version-20/05-pipes/readme_es.md @@ -0,0 +1,196 @@ +# 04 Pipes + +## Resumen + +Este ejemplo toma como punto de partida el ejemplo 03. + +Vamos a crear una pipe para realizar búsquedas sobre la lista de miembros + +Qué vamos a aprender en este ejemplo: + +- Creación de pipes personalizadas +- Utilización de pipes +- Utilización de variables de plantilla + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Creamos una caja para buscar por nombre + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Listado

+
++ ++ + + + + + + + + + + + + + + + +
AvatarIdName
+ + + {{ member.id }} + + {{ member.login }} +
+
+``` + +- Creamos una pipe utilizando el CLI + +```bash +ng generate pipe pipes/search-by-login +``` + +- Programamos la pipe + +La función `transform` de la pipe recibirá la lista de miembros sobre la que realizar la búsqueda y el valor por el que buscar. Devolverá una lista con los miembros cuyo campo contenga el valor buscado. + +_src/app/pipes/search-by-login.pipe.ts_ + +```diff +import { Pipe, PipeTransform } from '@angular/core'; ++import { MemberEntity } from '../models'; + +@Pipe({ + name: 'searchByLogin', + standalone: true, +}) +export class SearchByLoginPipe implements PipeTransform { + +- transform(value: unknown, ...args: unknown[]): unknown { +- return null; +- } ++ transform(members: MemberEntity[], value: string): MemberEntity[] { ++ return members.filter( ++ member => member.login.toLowerCase().includes(value.toLowerCase()) ++ ); ++ } + +} +``` + +- Aplicamos la pipe sobre la lista de miembros. + +El valor buscado lo obtenemos con una variable de plantilla declarada en el input de búsqueda. + +_src/app/user/user-list/user-list.component.ts_ + +```diff +import { Component, OnInit } from '@angular/core'; +import { MemberEntity } from '../../model'; +import { NgFor, NgIf } from '@angular/common'; +import { Highlight } from '../../directives/highlight'; +import { FormsModule } from '@angular/forms'; ++import { SearchByLoginPipe } from '../../pipes/search-by-login.pipe'; + +@Component({ + selector: 'app-user-list', + standalone: true, +- imports: [Highlight, FormsModule], ++ imports: [Highlight, FormsModule, SearchByLoginPipe], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +``` + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Listado

+
+ +- ++ + + + + + + + + + +- ++ + + + + + +
AvatarIdName
+ + + {{ member.id }} + + {{ member.login }} +
+
+``` + +Pero no ocurre nada, no está filtrando. Angular no encuentra motivo para revisar el html (que es cuando ejecutaría la pipe). + +- Nos enlazamos al evento que nos interese de la caja de búsqueda, para que cuando ocurra el evento Angular revise el html y se ejecute la pipe. + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Listado

+
+ +- ++ + + + + + + + + + + + + + + + +
AvatarIdName
+ + + {{ member.id }} + + {{ member.login }} +
+
+``` + +Y ahora sí, con cada pulsación de tecla, se realiza la búsqueda. diff --git a/04-frameworks/02-angular/version-20/06-services/readme_es.md b/04-frameworks/02-angular/version-20/06-services/readme_es.md new file mode 100644 index 000000000..76f2204d6 --- /dev/null +++ b/04-frameworks/02-angular/version-20/06-services/readme_es.md @@ -0,0 +1,107 @@ +# 05 Servicios + +## Resumen + +Este ejemplo toma como punto de partida el ejemplo 04. + +Vamos a crear un servicio que se encargue de conseguir la lista de miembros: + +Qué vamos a aprender en este ejemplo: + +- Creación de servicios +- Inyección de servicios + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Creamos un servicio con el CLI + +```bash +ng generate service services/members +``` + +- Programamos el servicio + +Creamos un método que nos devuelva una promesa de la lista de miembros. + +_src/app/services/members.service.ts_ + +```diff +import { Injectable } from '@angular/core'; ++import { MemberEntity } from '../model/MemberEntity'; + +@Injectable({ + providedIn: 'root' +}) +export class MembersService { + + constructor() { } + ++ getAll(): Promise { ++ return fetch(`https://api.github.com/orgs/lemoncode/members`) ++ .then((response) => response.json()); ++ } +} +``` + +- Inyectamos el servicio en el componente + +_src/app/user/user-list/user-list.component.ts_ + +```diff +import { Component, OnInit } from '@angular/core'; +import { MemberEntity } from '../../model'; +import { NgFor, NgIf } from '@angular/common'; +import { HighlightDirective } from '../../directives/highlight.directive'; +import { FormsModule } from '@angular/forms'; +import { SearchByLoginPipe } from '../../pipes/search-by-login.pipe'; ++import { MembersService } from '../../services/members.service'; + +@Component({ + selector: 'app-user-list', + standalone: true, + imports: [NgFor, HighlightDirective, FormsModule, NgIf, SearchByLoginPipe], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; + ++ constructor(private membersService: MembersService) {} + +``` + +- Pedimos los usuarios al servicio en vez de cogerlos directamente en el componente: + +_src/app/user/user-list/user-list.component.ts_ + +```diff + ngOnInit(): void { +- fetch('https://api.github.com/orgs/lemoncode/members') +- .then((response) => response.json()) +- .then((result) => (this.members = result)); ++ this.membersService.getAll().then((members) => (this.members = members)); ++ + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + } +``` diff --git a/04-frameworks/02-angular/version-20/07-reactive-form/readme_es.md b/04-frameworks/02-angular/version-20/07-reactive-form/readme_es.md new file mode 100644 index 000000000..749bce554 --- /dev/null +++ b/04-frameworks/02-angular/version-20/07-reactive-form/readme_es.md @@ -0,0 +1,503 @@ +# 06 Formularios reactivos (model-driven) + +## Resumen + +Este ejemplo toma como punto de partida el ejemplo 06. + +Vamos a crear un formulario model-driven para la edición de miembros: + +Qué vamos a aprender en este ejemplo: + +- Creación de formularios model-driven +- Validadores model-driven +- Directivas para formularios model-driven +- La propiedad valueChanges + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Importamos el módulo `ReactiveFormsModule` + +_src/app/user/user-list/user-list.component.ts_ + +```diff +import { Component, OnInit } from '@angular/core'; +import { MemberEntity } from '../../model'; +import { Highlight } from '../../directives/highlight.directive'; +-import { FormsModule } from '@angular/forms'; ++import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { SearchByLoginPipe } from '../../pipes/search-by-login.pipe'; +import { MembersService } from '../../services/members.service'; + +@Component({ + selector: 'app-user-list', + standalone: true, + imports: [ + Highlight, + FormsModule, + SearchByLoginPipe, ++ ReactiveFormsModule, + ], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +``` + +- Inyectamos el servicio `FormBuilder` en el ts + +_src/app/user/user-list/user-list.component.ts_ + +```diff +import { Component, OnInit } from '@angular/core'; +import { MemberEntity } from '../../model'; +import { Highlight } from '../../directives/highlight.directive'; +-import { FormsModule, ReactiveFormsModule } from '@angular/forms'; ++import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { SearchByLoginPipe } from '../../pipes/search-by-login.pipe'; +import { MembersService } from '../../services/members.service'; + +@Component({ + selector: 'app-user-list', + standalone: true, + imports: [ + Highlight, + FormsModule, + SearchByLoginPipe, + ReactiveFormsModule, + ], + templateUrl: './user-list.component.html', + styleUrl: './user-list.component.css', +}) +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; + +- constructor(private membersService: MembersService) {} ++ constructor(private membersService: MembersService, private fb: FormBuilder) {} + // .... +``` + +- Construimos un formulario en el ts + +_src/app/user/user-list/user-list.component.ts_ + +```ts +import { Component, OnInit } from "@angular/core"; +import { MemberEntity } from "../../model"; +import { NgFor, NgIf } from "@angular/common"; +import { Highlight } from "../../directives/highlight.directive"; +import { + FormBuilder, + /*diff*/ + FormControl, + FormGroup, + /*diff*/ + FormsModule, + ReactiveFormsModule, + /*diff*/ + Validators, + /*diff*/ +} from "@angular/forms"; +import { SearchByLoginPipe } from "../../pipes/search-by-login.pipe"; +import { MembersService } from "../../services/members.service"; + +@Component({ + selector: "app-user-list", + standalone: true, + imports: [ + Highlight, + FormsModule, + SearchByLoginPipe, + ReactiveFormsModule, + ], + templateUrl: "./user-list.component.html", + styleUrl: "./user-list.component.css", +}) +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; + /*diff*/ + editForm!: FormGroup; + idControl!: FormControl; + loginControl!: FormControl; + avatarControl!: FormControl; + /*diff*/ + + constructor( + private membersService: MembersService, + private fb: FormBuilder + ) {} + + add(): void { + this.members.push(this.newMember); + this.newMember = { + id: "", + login: "", + avatar_url: "", + }; + } + + handleFileInput($event: any) { + const files = $event.target.files as FileList; + const reader = new FileReader(); + reader.readAsDataURL(files[0]); + reader.onload = () => { + this.newMember.avatar_url = reader.result as string; + }; + } + + ngOnInit(): void { + this.membersService.getAll().then((members) => (this.members = members)); + + this.newMember = { + id: "", + login: "", + avatar_url: "", + }; + /*diff*/ + this.createEditForm(); + /*diff*/ + } + /*diff*/ + createEditForm(): void { + this.editForm = this.fb.group({ + id: ["", Validators.required], + login: ["", [Validators.required, Validators.minLength(6)]], + avatar_url: "", + }); + + this.idControl = this.editForm.get("id") as FormControl; + this.loginControl = this.editForm.get("login") as FormControl; + this.avatarControl = this.editForm.get("avatar_url") as FormControl; + } + /*diff*/ +} +``` + +Los formularios model-driven son muy flexibles en su forma de construirlos. + +Otra forma de hacerlo podría ser la siguiente: + +```ts +createEditForm() { + this.idControl = new FormControl('', Validators.required); + this.loginControl = new FormControl('', [Validators.required, Validators.minLength(6)]); + this.avatarControl = new FormControl(); + + this.editForm = this.fb.group({ + id: this.idControl, + login: this.loginControl, + avatar_url: this.avatarControl + }); +} +``` + +Y aquí otra alternativa más + +```ts +createEditForm() { + this.editForm = new FormGroup({}); + this.idControl = new FormControl('', Validators.required); + this.loginControl = new FormControl('', [Validators.required, Validators.minLength(6)]); + this.avatarControl = new FormControl(); + + this.editForm.addControl('id', this.idControl); + this.editForm.addControl('login', this.loginControl); + this.editForm.addControl('avatar_url', this.avatarControl); +} +``` + +Todas son equivalentes. + +- Rellenamos el formulario con los datos del `MemberEntity` en el que haga click el usuario + +_src/app/user/user-list/user-list.component.html_ + +```diff +

Listado

+
+ + + + + + + + + + + + @for (member of members | searchByLogin:search.value; track member) { +- ++ + + + + + } + +
AvatarIdName
+ + + {{ member.id }} + + {{ member.login }} +
+
+``` + +y en el ts + +```diff +export class UserListComponent implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; ++ memberSelected!: MemberEntity; + editForm!: FormGroup; + idControl!: FormControl; + loginControl!: FormControl; + avatarControl!: FormControl; + + // .... + + createEditForm(): void { + this.editForm = this.fb.group({ + id: ['', Validators.required], + login: ['', [Validators.required, Validators.minLength(6)]], + avatar_url: '', + }); + + this.idControl = this.editForm.get('id') as FormControl; + this.loginControl = this.editForm.get('login') as FormControl; + this.avatarControl = this.editForm.get('avatar_url') as FormControl; + } ++ ++ select(member: MemberEntity): void { ++ this.memberSelected = { ...member }; ++ this.editForm.patchValue(this.memberSelected); ++ } +} + +``` + +- Añadimos un formulario al final del html + +_src/app/user/user-list/user-list.component.html_ + +```html +
+

Datos de XXXXXXX

+
+
+ +
+
+ + +
+
El nombre es obligatorio
+
+ El nombre debe tener X caracteres mínimo. Tiene sólamente X + caracteres. +
+
+
+
+ + +
+
+ +
+
+``` + +- Enlazamos el HTML con el formulario model-driven + +_src/app/user/user-list/user-list.component.html_ + +```diff +
+

Datos de XXXXXXX

+-
++ +
+ +
+
+ +- ++ +
+
El nombre es obligatorio
+
El nombre debe tener X caracteres mínimo. Tiene sólamente X caracteres.
+
+
+
+ + +
+
+ +
+
+``` + +- Ponemos mensajes de validación y control del botón de submit + +_src/app/user/user-list/user-list.component.html_ + +```diff +
+

Datos de XXXXXXX

+
+
+ +
+
+ + +-
+-
El nombre es obligatorio
+-
El nombre debe tener X caracteres mínimo. Tiene sólamente X caracteres.
+-
++ @if (loginControl.invalid) { ++
++ @if (loginControl.errors?.['required']) { ++
El nombre es obligatorio
++ } ++ @if (loginControl.errors?.['minlength']) { ++
El nombre debe tener X caracteres mínimo. Tiene sólamente X caracteres.
++ } ++
++ } +
+
+ + +
+
+- ++ +
+
+``` + +- Gestión del input file + +_src/app/user/user-list/user-list.component.html_ + +```diff +
+

Datos de XXXXXXX

+
+
+ +
+
+ + + @if (loginControl.invalid) { +
+ @if (loginControl.errors?.['required']) { +
El nombre es obligatorio
+ } + @if (loginControl.errors?.['minlength']) { +
El nombre debe tener X caracteres mínimo. Tiene sólamente X caracteres.
+ } +
+ } +
+
+ +- +-
++ ++
+
+ +
+
+``` + +y en el ts + +```ts +// .... + handleEditFileInput($event: any): void { + const files = $event.target.files as FileList; + const reader = new FileReader(); + reader.readAsDataURL(files[0]); + reader.onload = () => { + this.avatarControl.setValue(reader.result); + }; + } +// .... +``` + +- Reemplazar el objeto original por el modificado + +_src/app/user/user-list/user-list.component.html_ + +```diff +
+

Datos de XXXXXXX

+
+
+ +
+
+ + +
+
El nombre es obligatorio
+
+ El nombre debe tener {{ loginControl.errors.minlength.requiredLength }} caracteres mínimo. + Tiene sólamente {{ loginControl.errors.minlength.actualLength }} +
+
+
+
+ + +
+
+- ++ +
+
+``` + +y en el ts + +```ts +save() { + if (this.editForm.valid) { + this.members = [...this.members]; + const member = this.editForm.value; + const index = this.members.findIndex(item => item.id === member.id); + this.members.splice(index, 1, member); + } +} +``` + +- La propiedad `valueChanges` + +Tanto `FormControl` como `FormGroup` como `FormArray` tienen una propiedad **valueChanges** que es un _Observable_ del value del objeto en cuestión. + +Esto permite suscribirnos a los cambios en un control o en un grupo de controles... diff --git a/04-frameworks/02-angular/version-20/08-http/readme_es.md b/04-frameworks/02-angular/version-20/08-http/readme_es.md new file mode 100644 index 000000000..7b1e2f445 --- /dev/null +++ b/04-frameworks/02-angular/version-20/08-http/readme_es.md @@ -0,0 +1,148 @@ +# 07 HTTP + +## Resumen + +Este ejemplo toma como punto de partida el ejemplo 06. + +Vamos a utilizar el Servicio HttpClient de Angular para realizar la petición del listado de miembros + +Qué vamos a aprender en este ejemplo: + +- Utilizar el servicio HttpClient para hacer peticiones + +Pasos: + +## Paso a Paso + +- Primero copiamos el ejemplo anterior, y hacemos un _npm install_ + +```bash +npm install +``` + +> NOTA: Podemos usar `npm ci` de esta forma instalaremos las dependencias existentes en `package-lock.json` en vez de regenerarlas. + +- Arrancamos la aplicación: + +```bash +ng serve +``` + +- Importamos el módulo HttpClientModule + +_src/app/app.config.ts_ + +```diff +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; ++import { provideHttpClient, withFetch } from '@angular/common/http'; + +// https://stackoverflow.com/questions/77483538/angular-17-http-client-injection +export const appConfig: ApplicationConfig = { +- providers: [provideRouter(routes)], ++ providers: [provideRouter(routes), provideHttpClient(withFetch())], +}; +``` + +- Inyectamos el servicio HttpClient en el servicio MembersService + +_src/app/services/members.service.ts_ + +```diff +import { Injectable } from '@angular/core'; +import { MemberEntity } from '../model/MemberEntity'; ++import { HttpClient } from '@angular/common/http'; + +@Injectable({ + providedIn: 'root' +}) +export class MembersService { + +- constructor() { } ++ constructor(private http: HttpClient) { } + + getAll(): Promise { + return fetch(`https://api.github.com/orgs/lemoncode/members`) + .then((response) => response.json()); + } +} +``` + +- Cambiamos la llamada al fetch() por una llamada al get() de HttpClient + +_src/app/services/members.service.ts_ + +```diff +import { Injectable } from '@angular/core'; +import { MemberEntity } from '../model/MemberEntity'; +import { HttpClient } from '@angular/common/http'; + +@Injectable({ + providedIn: 'root' +}) +export class MembersService { + + constructor(private http: HttpClient) { } + + getAll(): Promise { +- return (`https://api.github.com/orgs/lemoncode/members`) +- .then((response) => response.json()); ++ return this.http.get(`https://api.github.com/orgs/lemoncode/members`); + } +} +``` + +Por defecto, los métodos de HttpClient convierten el body de la respuesta en json, por lo que nos ahorramos hacerlo nosotros. + +- Tipamos correctamente + +Los métodos de HttpClient no devuelven promesas de las respuestas, devuelven Observables de las respuestas. + +_src/app/services/members.service.ts_ + +```diff +import { Injectable } from '@angular/core'; +import { MemberEntity } from '../model/MemberEntity'; +import { HttpClient } from '@angular/common/http'; ++import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class MembersService { + + constructor(private http: HttpClient) { } + +- getAll(): Promise { +- return this.http.get(`https://api.github.com/orgs/lemoncode/members`); +- } ++ getAll(): Observable { ++ return this.http.get(`https://api.github.com/orgs/lemoncode/members`); ++ } +} +``` + +- Adaptamos la llamada al servicio desde el componente + +Ahora el componente UserListComponent no recibe una promesa, sino un observable. Lo correjimos. + +_src/app/user/user-list/user-list.component.ts_ + +```diff + ngOnInit(): void { +- this.membersService.getAll().then((members) => (this.members = members)); ++ this.membersService ++ .getAll() ++ .subscribe((members) => (this.members = members)); ++ + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + + this.createEditForm(); + } +``` diff --git a/04-frameworks/02-angular/version-20/package.json b/04-frameworks/02-angular/version-20/package.json new file mode 100644 index 000000000..a4bf8d041 --- /dev/null +++ b/04-frameworks/02-angular/version-20/package.json @@ -0,0 +1,16 @@ +{ + "name": "version-20", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@angular/cli": "^20.3.20" + } +} diff --git a/04-frameworks/02-angular/version-20/playground/.editorconfig b/04-frameworks/02-angular/version-20/playground/.editorconfig new file mode 100644 index 000000000..f166060da --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/04-frameworks/02-angular/version-20/playground/.gitignore b/04-frameworks/02-angular/version-20/playground/.gitignore new file mode 100644 index 000000000..b1d225e26 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/.gitignore @@ -0,0 +1,43 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/04-frameworks/02-angular/version-20/playground/README.md b/04-frameworks/02-angular/version-20/playground/README.md new file mode 100644 index 000000000..809e270e6 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/README.md @@ -0,0 +1,59 @@ +# Playground + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.20. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/04-frameworks/02-angular/version-20/playground/angular.json b/04-frameworks/02-angular/version-20/playground/angular.json new file mode 100644 index 000000000..bedc91448 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/angular.json @@ -0,0 +1,92 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "playground": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "playground:build:production" + }, + "development": { + "buildTarget": "playground:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular/build:extract-i18n" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ] + } + } + } + } + } +} diff --git a/04-frameworks/02-angular/version-20/playground/package.json b/04-frameworks/02-angular/version-20/playground/package.json new file mode 100644 index 000000000..4ffff84db --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/package.json @@ -0,0 +1,48 @@ +{ + "name": "playground", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "prettier": { + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] + }, + "private": true, + "dependencies": { + "@angular/common": "^20.3.0", + "@angular/compiler": "^20.3.0", + "@angular/core": "^20.3.0", + "@angular/forms": "^20.3.0", + "@angular/platform-browser": "^20.3.0", + "@angular/router": "^20.3.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular/build": "^20.3.20", + "@angular/cli": "^20.3.20", + "@angular/compiler-cli": "^20.3.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.9.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.9.2" + } +} \ No newline at end of file diff --git a/04-frameworks/02-angular/version-20/playground/public/favicon.ico b/04-frameworks/02-angular/version-20/playground/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/04-frameworks/02-angular/version-20/playground/public/favicon.ico differ diff --git a/04-frameworks/02-angular/version-20/playground/src/app/app.config.ts b/04-frameworks/02-angular/version-20/playground/src/app/app.config.ts new file mode 100644 index 000000000..d953f4c41 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/app.config.ts @@ -0,0 +1,12 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes) + ] +}; diff --git a/04-frameworks/02-angular/version-20/playground/src/app/app.css b/04-frameworks/02-angular/version-20/playground/src/app/app.css new file mode 100644 index 000000000..e69de29bb diff --git a/04-frameworks/02-angular/version-20/playground/src/app/app.html b/04-frameworks/02-angular/version-20/playground/src/app/app.html new file mode 100644 index 000000000..80d39d5c1 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/app.html @@ -0,0 +1,7 @@ +

Hola Mundo

+ + + + + + diff --git a/04-frameworks/02-angular/version-20/playground/src/app/app.routes.ts b/04-frameworks/02-angular/version-20/playground/src/app/app.routes.ts new file mode 100644 index 000000000..dc39edb5f --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/04-frameworks/02-angular/version-20/playground/src/app/app.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/app.spec.ts new file mode 100644 index 000000000..981a28313 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/app.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, playground'); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/app.ts b/04-frameworks/02-angular/version-20/playground/src/app/app.ts new file mode 100644 index 000000000..bfee264a3 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/app.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { Search } from './utils/search/search'; +import { UserList } from './user/user-list/user-list'; +import { Menu } from './layout/menu/menu'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, Search, UserList, Menu], + templateUrl: './app.html', + styleUrl: './app.css', +}) +export class App { + title = 'pplayground'; + + escribeLog(name: string) { + console.log(name); + } +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/directives/highlight.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/directives/highlight.spec.ts new file mode 100644 index 000000000..3a7f2e776 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/directives/highlight.spec.ts @@ -0,0 +1,8 @@ +import { Highlight } from './highlight'; + +describe('Highlight', () => { + it('should create an instance', () => { + const directive = new Highlight(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/directives/highlight.ts b/04-frameworks/02-angular/version-20/playground/src/app/directives/highlight.ts new file mode 100644 index 000000000..a3af596c6 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/directives/highlight.ts @@ -0,0 +1,30 @@ +import { Directive, ElementRef, HostListener, Input } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', +}) +export class Highlight { + // color = 'lightblue'; + @Input('appHighlight') + public color!: string; + + private defaultColor = 'lightblue'; + + constructor(private el: ElementRef) { + // (el.nativeElement as HTMLElement).style.backgroundColor = 'lightblue'; + } + + private highlight(color: string) { + (this.el.nativeElement as HTMLElement).style.backgroundColor = color; + } + + @HostListener('mouseenter') + onMouseEneterEvent() { + this.highlight(this.color || this.defaultColor); + } + + @HostListener('mouseleave') + onMouseLeaveEvent() { + this.highlight(''); + } +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.css b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.css new file mode 100644 index 000000000..e69de29bb diff --git a/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.html b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.html new file mode 100644 index 000000000..3edb2c049 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.html @@ -0,0 +1,4 @@ + + + +

menu works!

diff --git a/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.spec.ts new file mode 100644 index 000000000..eafdb54f8 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Menu } from './menu'; + +describe('Menu', () => { + let component: Menu; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Menu] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Menu); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.ts b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.ts new file mode 100644 index 000000000..3ee4bae34 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/layout/menu/menu.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { Highlight } from '../../directives/highlight'; + +@Component({ + selector: 'app-menu', + imports: [Highlight], + templateUrl: './menu.html', + styleUrl: './menu.css', +}) +export class Menu { + public variableColorEnElTLS = 'pink' +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/models/index.ts b/04-frameworks/02-angular/version-20/playground/src/app/models/index.ts new file mode 100644 index 000000000..7da2365d3 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/models/index.ts @@ -0,0 +1,5 @@ +export interface MemberEntity { + avatar_url: string; + id: string; + login: string; +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/pipes/search-by-login-pipe.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/pipes/search-by-login-pipe.spec.ts new file mode 100644 index 000000000..77f9eedc4 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/pipes/search-by-login-pipe.spec.ts @@ -0,0 +1,8 @@ +import { SearchByLoginPipe } from './search-by-login-pipe'; + +describe('SearchByLoginPipe', () => { + it('create an instance', () => { + const pipe = new SearchByLoginPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/pipes/search-by-login-pipe.ts b/04-frameworks/02-angular/version-20/playground/src/app/pipes/search-by-login-pipe.ts new file mode 100644 index 000000000..e7ce8aa0b --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/pipes/search-by-login-pipe.ts @@ -0,0 +1,11 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { MemberEntity } from '../models'; + +@Pipe({ + name: 'searchByLogin', +}) +export class SearchByLoginPipe implements PipeTransform { + transform(members: MemberEntity[], value: string): MemberEntity[] { + return members.filter((m) => m.login.toLowerCase().includes(value.toLowerCase())); + } +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/services/members.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/services/members.spec.ts new file mode 100644 index 000000000..0d2170a8e --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/services/members.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Members } from './members'; + +describe('Members', () => { + let service: Members; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Members); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/services/members.ts b/04-frameworks/02-angular/version-20/playground/src/app/services/members.ts new file mode 100644 index 000000000..b9e0693bc --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/services/members.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { MemberEntity } from '../models'; + +@Injectable({ + providedIn: 'root', +}) +export class Members { + getAll(): Promise { + return fetch('https://api.github.com/orgs/lemoncode/members').then((r) => r.json()); + } +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.css b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.css new file mode 100644 index 000000000..d6b89814b --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.css @@ -0,0 +1,38 @@ +/* Estilos generales para la tabla */ +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; /* Ajusta el margen según sea necesario */ +} + +/* Estilos para las celdas del encabezado */ +th { + background-color: #f2f2f2; /* Color de fondo del encabezado */ + padding: 10px; + text-align: left; +} + +/* Estilos para las celdas del cuerpo de la tabla */ +td { + padding: 10px; + border-bottom: 1px solid #ddd; /* Línea divisoria entre filas */ +} + +/* Estilos para las imágenes dentro de las celdas */ +img { + max-width: 50px; /* Ajusta el tamaño máximo de las imágenes */ + border-radius: 50%; /* Borde redondeado para las imágenes */ +} + +/* Estilos para las filas impares, para mejorar la legibilidad */ +tbody tr:nth-child(odd) { + background-color: #f9f9f9; /* Fondo alternativo para filas impares */ +} + +.ng-valid[required], .ng-valid.required { + border-left: 5px solid #42A948; +} + +.ng-invalid:not(form) { + border-left: 5px solid #a94442; +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.html b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.html new file mode 100644 index 000000000..3de51db4b --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.html @@ -0,0 +1,97 @@ +

Alta

+
+
+
+ + + @if (id.invalid && id.touched) { +
El id es obligatorio
+ } +
+
+ + + @if (name.invalid && name.touched) { + @if (name.errors!['required']) { +
El nombre es obligatorio
+ } + @if (name.errors!['minlength']) { +
+ El nombre debe tener {{ name.errors!['minlength'].requiredLength }} caracteres mínimo. + Tiene solamente {{ name.errors!['minlength'].actualLength }} +
+ } + } +
+
+ + +
+
+ +
+
+ +

Listado

+ +
+ + + + + + + + + + + + @for (member of members | searchByLogin: search.value; track member) { + + + + + + } + +
AvatarIdName
+ + + {{ member.id }} + + {{ member.login }} +
+
+ +
+

Datos de XXXXXXX

+
+
+ +
+
+ + + + @if (loginControl.invalid) { +
+ @if (loginControl.errors?.['required']) { +
El nombre es obligatorio
+ } + @if (loginControl.errors?.['minlength']) { +
El nombre debe tener X caracteres mínimo. Tiene sólamente X caracteres.
+ } +
+ } +
+
+ + +
+
+ +
+
diff --git a/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.spec.ts new file mode 100644 index 000000000..f9c7bf4f1 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserList } from './user-list'; + +describe('UserList', () => { + let component: UserList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserList] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UserList); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.ts b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.ts new file mode 100644 index 000000000..317dd21b1 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/user/user-list/user-list.ts @@ -0,0 +1,105 @@ +import { Component, OnInit } from '@angular/core'; +import { MemberEntity } from '../../models'; +import { Highlight } from '../../directives/highlight'; +import { + FormBuilder, + FormControl, + FormGroup, + FormsModule, + Validators, + ReactiveFormsModule, +} from '@angular/forms'; +import { SearchByLoginPipe } from '../../pipes/search-by-login-pipe'; +import { Members } from '../../services/members'; + +@Component({ + selector: 'app-user-list', + imports: [Highlight, FormsModule, SearchByLoginPipe, ReactiveFormsModule], + templateUrl: './user-list.html', + styleUrl: './user-list.css', +}) +export class UserList implements OnInit { + members: MemberEntity[] = []; + newMember!: MemberEntity; + memberSelected!: MemberEntity; + + editForm!: FormGroup; + idControl!: FormControl; + loginControl!: FormControl; + avatarControl!: FormControl; + + constructor( + private membersService: Members, + private fb: FormBuilder, + ) {} + + add(): void { + this.members.push(this.newMember); + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + } + + handleFileInput($event: any) { + const files = $event.target.files as FileList; + const reader = new FileReader(); + reader.readAsDataURL(files[0]); + reader.onload = () => { + this.newMember.avatar_url = reader.result as string; + }; + } + + ngOnInit(): void { + this.members = [ + { id: 'foo', login: 'fooo', avatar_url: '' }, + { id: 'bioa', login: 'bioao', avatar_url: '' }, + { id: 'bb', login: 'bbo', avatar_url: '' }, + ]; + // this.membersService.getAll().then((members) => (this.members = members)); + + this.newMember = { + id: '', + login: '', + avatar_url: '', + }; + + this.createEditForm(); + } + + createEditForm(): void { + this.editForm = this.fb.group({ + id: ['', Validators.required], + login: ['', [Validators.required, Validators.minLength(6)]], + avatar_url: '', + }); + + this.idControl = this.editForm.get('id') as FormControl; + this.loginControl = this.editForm.get('login') as FormControl; + this.avatarControl = this.editForm.get('avatar_url') as FormControl; + } + + select(member: MemberEntity): void { + this.memberSelected = { ...member }; + this.editForm.patchValue(this.memberSelected); + } + + handleEditFileInput($event: any): void { + const files = $event.target.files as FileList; + const reader = new FileReader(); + reader.readAsDataURL(files[0]); + reader.onload = () => { + this.avatarControl.setValue(reader.result); + }; + } + + save() { + if (this.editForm.valid) { + this.members = [...this.members]; + const member = this.editForm.value; + const index = this.members.findIndex((i) => i.id === member.id); + this.members.splice(index, 1, member); + } + } +} diff --git a/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.css b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.css new file mode 100644 index 000000000..d3b2b04ea --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.css @@ -0,0 +1,47 @@ +p { + color: blue; +} + +.container { + position: relative; + padding: 0; + margin: 0; + border: 0; + width: 150px; + height: 30px; +} + +.field { + width: 100%; + height: 100%; + border-radius: 3px; +} + +.icons-container { + position: absolute; + top: 5px; + right: -10px; + width: 30px; + height: 30px; +} + +.icon-search { + position: relative; + width: 50%; + height: 50%; + opacity: 1; + border-radius: 50%; + border: 3px solid #c7d0f8; +} + +.icon-search:after { + content: ""; + position: absolute; + bottom: -9px; + right: -2px; + width: 4px; + border-radius: 3px; + transform: rotate(-45deg); + height: 10px; + background-color: #c7d0f8; +} \ No newline at end of file diff --git a/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.html b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.html new file mode 100644 index 000000000..da175e6b9 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.html @@ -0,0 +1,8 @@ +

{{ label }}

+ +
+ +
+ +
+
\ No newline at end of file diff --git a/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.spec.ts b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.spec.ts new file mode 100644 index 000000000..5eae06ebf --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Search } from './search'; + +describe('Search', () => { + let component: Search; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Search] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Search); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.ts b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.ts new file mode 100644 index 000000000..11c8651cd --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/app/utils/search/search.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-search', + imports: [], + templateUrl: './search.html', + styleUrl: './search.css', +}) +export class Search { + @Input() ph = 'Buscar...'; + @Input() label = 'Buscar'; + + name = 'Paco'; + + @Output() clickEnLupa: EventEmitter = new EventEmitter(); + @Output() otroEvento = new EventEmitter(); + + changeName() { + this.name = 'Francisco'; + this.clickEnLupa.emit(this.name) + } +} diff --git a/04-frameworks/02-angular/version-20/playground/src/index.html b/04-frameworks/02-angular/version-20/playground/src/index.html new file mode 100644 index 000000000..9757aba6a --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/index.html @@ -0,0 +1,13 @@ + + + + + Playground + + + + + + + + diff --git a/04-frameworks/02-angular/version-20/playground/src/main.ts b/04-frameworks/02-angular/version-20/playground/src/main.ts new file mode 100644 index 000000000..5df75f9c8 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/04-frameworks/02-angular/version-20/playground/src/styles.css b/04-frameworks/02-angular/version-20/playground/src/styles.css new file mode 100644 index 000000000..90d4ee007 --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/04-frameworks/02-angular/version-20/playground/tsconfig.app.json b/04-frameworks/02-angular/version-20/playground/tsconfig.app.json new file mode 100644 index 000000000..264f459bf --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/04-frameworks/02-angular/version-20/playground/tsconfig.json b/04-frameworks/02-angular/version-20/playground/tsconfig.json new file mode 100644 index 000000000..e4955f26b --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/tsconfig.json @@ -0,0 +1,34 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/04-frameworks/02-angular/version-20/playground/tsconfig.spec.json b/04-frameworks/02-angular/version-20/playground/tsconfig.spec.json new file mode 100644 index 000000000..940b30a5f --- /dev/null +++ b/04-frameworks/02-angular/version-20/playground/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/08-mobile/01-pwa/doc/notificaciones.md b/08-mobile/01-pwa/doc/notificaciones.md index 91a1d96ea..b76331024 100644 --- a/08-mobile/01-pwa/doc/notificaciones.md +++ b/08-mobile/01-pwa/doc/notificaciones.md @@ -8,7 +8,7 @@ Las notificaciones pueden proporcionar grandes beneficios a los usuarios si se u - No están asociadas a un Service Worker. - No se pueden definir acciones. -Los navegadores que soportan notificaciones disponen de una clase Notification. Basta con hacer “new Notification” parar crear y mostrar una notificación. +Los navegadores que soportan notificaciones disponen de una clase `Notification`. Basta con hacer “new Notification” parar crear y mostrar una notificación. ```javascript var n = new Notification('Título de la notificación', { @@ -25,7 +25,7 @@ https://developer.mozilla.org/es/docs/Web/API/notification Estas notificaciones se gestionan desde un Service Worker, por lo que el contexto será el del worker. -Para crear y mostrar una notificación hay que llamar al método .showNotification del .registration del worker. +Para crear y mostrar una notificación hay que llamar al método `.showNotification` del `.registration` del worker. ```javascript self.registration.showNotification('Título de la notificación’, { @@ -92,11 +92,3 @@ actions: [{ icon: 'path/icono' }], ``` - - - - - - - - diff --git a/08-mobile/01-pwa/doc/notificaciones_push.md b/08-mobile/01-pwa/doc/notificaciones_push.md index a7ee2ee02..5674dd31d 100644 --- a/08-mobile/01-pwa/doc/notificaciones_push.md +++ b/08-mobile/01-pwa/doc/notificaciones_push.md @@ -12,9 +12,6 @@ Para poder recibir notificaciones push tenemos que implementar 3 fases - Enviar el mensaje - Recibir el mensaje y mostrar una notificación - - - Configurar Push - Paso 1 Nuestro servidor tiene que servir una página con un VAPID key con parte pública y privada