0
\$\begingroup\$

I recently took a class on Angular 2+ at Code School.

I then rebuilt my homepage using Angular 4.

I would greatly appreciate if any developer experienced with Angular 2+ can check out my code on git and offer constructive feedback.

This site features loading content from JSON services into dynamic templates, unit tests, and advanced CSS.

Full code here: https://github.com/garyv/garyv

Projects page here: http://garyvonschilling.com/work

<!-- projects.component.html -->
<div class='big-margin-bottom'>
  <h3>Technology used:</h3>
  <ul class='tags row'>
    <li *ngFor="let tag of projectTags.tags" 
        class="col tag" [class.active]="projectTags.isActive(tag)" 
        (click)="toggleTag(tag)">
      {{tag}} 
      <i [class.fa]="true" 
         [class.fa-check]="projectTags.isActive(tag)"
         [class.fa-minus-circle]="!projectTags.isActive(tag)"></i>
    </li>
  </ul>
</div>

<div class='row' [class.fade-in]="!skipFade">
  <div *ngIf="!projectTags.activeProjects.length">
    <h4>
      Click a tag above to see projects
      <i class='fa fa-level-up'></i>
    </h4>
  </div>
  <div *ngFor="let project of projectTags.activeProjects"
       class='col project grid-4 half big-margin-bottom medium-padding' 
       [class.active]="project.active" [class.hidden]="!project.active">
    <h4>
      <a [routerLink]="[project.friendlyId]">{{project.title}}</a>
    </h4>
    <a *ngIf="project.image?.src" [routerLink]="[project.friendlyId]">
      <img alt='' 
       [src]="project.image.src" 
       [srcset]="project.image.srcset" 
       sizes="(min-width: 37.5em) 30vw, 48vw" />
    </a>
    <div class='tags'>
      Tags: 
      <span [innerHTML]="projectTags.activeProjectTags(project)"></span>
    </div>
  </div>

</div>
// projects.component.ts
import { Component, Input, OnInit } from '@angular/core';

import { Project } from './project/project.model';
import { ProjectTags } from './project-tags.model';
import { ProjectsService } from './projects.service';
import { StateService } from '../state/state.service';

@Component({
  selector: 'app-projects',
  templateUrl: './projects.component.html',
  styleUrls: ['./projects.component.css']
})
export class ProjectsComponent implements OnInit {

  projects: Project[];
  projectTags: ProjectTags;
  @Input() skipFade: boolean;

  constructor(private projectsService: ProjectsService) { }

  ngOnInit() {
    this.projectTags = new ProjectTags();

    this.projectsService.getProjects()
      .subscribe( (projects) => {
        this.projects = projects;
        this.projectTags.populateTags(projects);
      });
  }

  toggleTag(tag) {
    this.projectTags.toggleTag(tag, this.projects);
  }
}
// projects.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By }              from '@angular/platform-browser';
import { DebugElement }    from '@angular/core';
import { HttpModule } from '@angular/http';
import { Observable } from 'rxjs/Observable';

import 'rxjs/add/observable/of';

import { ProjectsComponent } from './projects.component';

import { Project } from './project/project.model';
import { ProjectComponent } from './project/project.component';
import { ProjectTags } from './project-tags.model';
import { ProjectsService } from './projects.service';
import { StateService } from '../state/state.service';

import { Router, RouterModule } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';

describe('ProjectsComponent', () => {
  let component: ProjectsComponent;
  let fixture: ComponentFixture<ProjectsComponent>;
  let debugElement: DebugElement;

  let projectsService: ProjectsService;

  let mockProjects: Project[] = [
    { 
      friendlyId: 'example-title',
      image: {
        src: 'https://www.fillmurray.com/400/300/',
      },
      link: {
        address: '//example.com',
        text: 'example text'
      },
      text: '<p>This is an example project.</p>',
      title: 'Example Title',
      tags: ['Example Tag', 'Same']
    }, 
    { 
      friendlyId: 'lorem-ipsum-title',
      image: {
        src: 'https://www.fillmurray.com/400/300/g',
      },
      link: {
        address: '//lorempixel.com',
        text: 'lorem ipsum'
      },
      text: '<p>Lorem ipsum dolor sit amet.</p>',
      title: 'Lorem Ipsum Title',
      tags: ['Lorem', 'Same']
    }
  ];

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterModule, 
        HttpModule,
        RouterTestingModule.withRoutes(
          [{path: 'work/:friendly-id', component: ProjectComponent}]
        )
      ],
      declarations: [ 
        ProjectsComponent,
        ProjectComponent
      ],  
      providers: [
        ProjectsService,
        StateService
      ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(ProjectsComponent);
    component = fixture.componentInstance;
    debugElement = fixture.debugElement;

    projectsService = fixture.debugElement.injector.get(ProjectsService);

    spyOn(projectsService, 'getProjects')
      .and.returnValue(Observable.of(mockProjects));

    StateService.set('tags', '["Same"]');
    fixture.detectChanges();
  });

  it('should be created', () => {
    expect(component).toBeTruthy();
  });

  it('should list tags', () => {
    let tagsElement = debugElement.query(By.css('.tags li'));
    expect(tagsElement.nativeElement.textContent).toContain('Example Tag'); 
  });

  it('should list projects', () => {
    let projectElements = debugElement.query(By.css('.project'));
    expect(projectElements.nativeElement.textContent).toContain('Example Title'); 
  });

  it('should link to individual project page', () => {
    let projectLink = debugElement.query(By.css('a[href$=example-title]')); 
    expect(projectLink).toBeTruthy();   
  });

  it('should hide inactive projects', () => {
    component.projectTags.activeProjects.splice(0, 1);
    fixture.detectChanges();
    let projectElements = debugElement.query(By.css('.project'));
    expect(projectElements.nativeElement.textContent).toContain('Lorem Ipsum'); 
  });
});
// projects.service.ts
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import 'rxjs/add/operator/map';

import { Project } from './project/project.model';

@Injectable()
export class ProjectsService {

  constructor(private http:Http) {}

  getProjects() {
    return this.http.get('app/projects/projects.json')
      .map( (response) => {
        let projects = <Project[]>response.json().projects;
        for (let project of projects) {
          if (project.title && !project.friendlyId) {
            project.friendlyId = ProjectsService.getFriendlyId(project.title);
          }                   
        }
      return projects;
     });
  }

  static getFriendlyId(title: string): string {
    return title.toLowerCase()
      .replace(/\W+/g, '-')
      .replace(/^-|-$/g, '');
  }
}
// projects.service.spec.ts
import { TestBed, async, inject } from '@angular/core/testing';
import { 
  HttpModule, 
  Http, 
  Response,
  ResponseOptions, 
  XHRBackend
} from '@angular/http';
import { MockBackend } from '@angular/http/testing';

import { ProjectsService } from './projects.service';

import { Project } from './project/project.model';

describe('ProjectsService', () => {
  let projectsService: ProjectsService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule],
      providers: [
        ProjectsService,
        { provide: XHRBackend, useClass: MockBackend }
      ]
    });
  });

  describe('getProjects()', () => {

    it('should return an Observable<Project[]>', 

      inject([ProjectsService, XHRBackend], (projectsService, mockBackend) => {

        const mockResponse = {
          projects: [
            { 
              //friendlyId: 'example-title',
              image: {
                src: 'https://www.fillmurray.com/400/300/',
              },
              link: {
                address: '//example.com',
                text: 'example text'
              },
              text: '<p>This is an example project.</p>',
              title: 'Example Title',
              tags: ['Example Tag', 'Same']
            }, 
            { 
              //friendlyId: 'lorem-ipsum',
              image: {
                src: 'http://lorempixel.com/400/300/',
              },
              link: {
                address: '//lipsum.com',
                text: 'lorem ipsum'
              },
              text: '<p>Lorem ipsum dolor sit amet.</p>',
              title: 'Lorem Ipsum',
              tags: ['Lorem', 'Same']
            }
          ]
        };

        mockBackend.connections.subscribe( (connection) => {
          let response = new ResponseOptions({body: JSON.stringify(mockResponse)});
          connection.mockRespond(new Response(response));
        });

        projectsService.getProjects().subscribe( (projects) => {
          expect(projects.length).toEqual(2);     
          expect(projects[0].title).toEqual('Example Title');
          expect(projects[1].title).toEqual('Lorem Ipsum');
          expect(projects[1].friendlyId).toEqual('lorem-ipsum');
          expect(projects[1].image).toEqual({src: 'http://lorempixel.com/400/300/'});
          expect(projects[1].link).toEqual({address: '//lipsum.com', text: 'lorem ipsum'});
          expect(projects[1].text).toEqual('<p>Lorem ipsum dolor sit amet.</p>');
          expect(projects[1].tags).toEqual(['Lorem', 'Same']);
        });
      })
    );

    describe('getFriendlyId()', () => {
      it('should make titles url friendly', () => {
        let friendlyId = ProjectsService.getFriendlyId(" ¿ HellO  World #👋🌎 !! ");
        expect(friendlyId).toEqual('hello-world');
      });
    });

  });
});
// projects-tags.model.ts
import { Input, OnInit } from '@angular/core';

import { Project } from './project/project.model';
import { StateService } from '../state/state.service';

const defaultTags:string[] = ['JavaScript', 'Ruby on Rails'];
const storageKey:string = 'tags';

export class ProjectTags {
  tags: string[] = [];
  activeTags: string[] = [];
  activeProjects: Project[] = [];
  @Input() highlightTag: string;

  constructor() { 
    this.activeTags = JSON.parse(StateService.get(storageKey));
    if (!this.activeTags || !this.activeTags.length) {
      this.activeTags = defaultTags; 
    }
  }

  isActive(tag:string):boolean {
    return this.activeTags.indexOf(tag) != -1;
  }

  populateTags(projects:Project[]):void {
    this.activeProjects = [];
    for (let project of projects) {
      project.active = false;
      for (let tag of project.tags) {
        if (this.tags.indexOf(tag) == -1) {
          this.tags.push(tag);
        }
        if (!project.active && this.activeTags.indexOf(tag) != -1) {
          project.active = true;
        }
      }
      if (project.active) {
        this.activeProjects.push(project);
      }
    }
    this.tags = this.tags.sort();
    StateService.set(storageKey, JSON.stringify(this.activeTags));
  }

  toggleTag(tag:string, projects:Project[]):void {
    let index = this.activeTags.indexOf(tag);

    if (index == -1) {
      this.highlightTag = tag;
      this.activeTags.push(tag);
    } else {
      this.highlightTag = null;
      this.activeTags.splice(index, 1);
    }

    this.populateTags(projects);
  }

  activeProjectTags(project:Project):string {
    let tags:string[] = [];
    for (let tag of project.tags) {
      if (this.isActive(tag)) {
        if (tag == this.highlightTag) {
          tags.push(`<span class='highlight'>${tag}</span>`);
        } else {
          tags.push(`<span>${tag}</span>`);
        }
      }
    }
    return tags.join(', ');
  }
}
\$\endgroup\$
0

1 Answer 1

1
\$\begingroup\$

Few remarks:

In case my service makes a network call I call them providers. So ProjectsProvider in your case. Why? First, they are providing you with data. Second and most important, now you know you don't need to unsubscribe.

Another thing that stands out is that you never seem to use ===. Always ==. Unless there is a specific reason, its better to use === and !==. A TSlint plugin might be handy.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.