├── .angular-playground ├── .gitignore ├── angular-playground.json ├── main.playground.ts └── tsconfig.playground.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .docker ├── config │ └── nginx.conf ├── nginx.dev.dockerfile ├── nginx.dockerfile └── node.dockerfile ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ ├── angular-jumpstart-api-AutoDeployTrigger-aadaf67b-44be-4e1b-952f-e7688098b8cb.yml │ ├── angular-jumpstart-ui-AutoDeployTrigger-2ef119d3-60ef-4c90-b01b-a766e68c5778.yml │ └── azure-static-web-apps-jolly-sand-0e24e951e.yml ├── .gitignore ├── .k8s ├── nginx.deployment.yml ├── nginx.service.yml ├── node.deployment.yml └── node.service.yml ├── .storybook ├── main.js ├── preview-head.html ├── preview.js ├── tsconfig.json └── typings.d.ts ├── .vscode ├── extensions.json ├── launch.json ├── setting.json ├── settings.json └── tasks.json ├── LICENSE.txt ├── README.md ├── angular.json ├── api ├── .funcignore ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── CustomerById │ ├── function.json │ └── index.js ├── CustomerCreate │ ├── function.json │ └── index.js ├── CustomerDelete │ ├── function.json │ └── index.js ├── CustomerUpdate │ ├── function.json │ └── index.js ├── CustomersAll │ ├── function.json │ └── index.js ├── CustomersPage │ ├── function.json │ └── index.js ├── Login │ ├── function.json │ └── index.js ├── Logout │ ├── function.json │ └── index.js ├── OrderById │ ├── function.json │ └── index.js ├── README.md ├── StatesAll │ ├── function.json │ └── index.js ├── data │ ├── customers.json │ └── states.json ├── host.json ├── package-lock.json ├── package.json └── proxies.json ├── cypress.config.ts ├── cypress ├── e2e │ ├── customers.spec.cy.ts │ └── login.spec.cy.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ ├── e2e.ts │ └── index.js └── tsconfig.json ├── docker-compose.yml ├── package-lock.json ├── package.json ├── public ├── data │ ├── customers.json │ └── states.json ├── favicon.ico ├── images │ ├── female.png │ ├── male.png │ ├── people.png │ ├── screenshots │ │ ├── cards.png │ │ ├── details.png │ │ ├── grid.png │ │ └── orders.png │ └── spinner.gif └── staticwebapp.config.json ├── server.js ├── skaffold.yaml ├── src ├── app │ ├── about │ │ ├── about.component.html │ │ ├── about.component.sandbox.ts │ │ └── about.component.ts │ ├── app.component.css │ ├── app.component.html │ ├── app.component.ts │ ├── core │ │ ├── growler │ │ │ ├── growler.component.css │ │ │ ├── growler.component.ts │ │ │ └── growler.service.ts │ │ ├── interceptors │ │ │ └── auth.interceptor.ts │ │ ├── modal │ │ │ ├── modal.component.css │ │ │ ├── modal.component.html │ │ │ ├── modal.component.ts │ │ │ └── modal.service.ts │ │ ├── navbar │ │ │ ├── navbar.component.html │ │ │ └── navbar.component.ts │ │ ├── overlay │ │ │ ├── overlay-request-response.interceptor.ts │ │ │ ├── overlay.component.css │ │ │ ├── overlay.component.html │ │ │ └── overlay.component.ts │ │ ├── services │ │ │ ├── auth.service.ts │ │ │ ├── data.service.ts │ │ │ ├── dialog.service.ts │ │ │ ├── event-bus.service.ts │ │ │ ├── filter.service.ts │ │ │ ├── logger.service.ts │ │ │ ├── property-resolver.ts │ │ │ ├── sorter.service.ts │ │ │ ├── trackby.service.ts │ │ │ ├── utilities.service.ts │ │ │ └── validation.service.ts │ │ └── strategies │ │ │ └── preload-modules.strategy.ts │ ├── customer │ │ ├── customer-details │ │ │ ├── customer-details.component.css │ │ │ ├── customer-details.component.html │ │ │ ├── customer-details.component.sandbox.ts │ │ │ └── customer-details.component.ts │ │ ├── customer-edit │ │ │ ├── customer-edit.component.css │ │ │ ├── customer-edit.component.html │ │ │ └── customer-edit.component.ts │ │ ├── customer-orders │ │ │ ├── customer-orders.component.html │ │ │ ├── customer-orders.component.sandbox.ts │ │ │ └── customer-orders.component.ts │ │ ├── customer.component.html │ │ ├── customer.component.ts │ │ ├── guards │ │ │ ├── can-activate.guard.ts │ │ │ └── can-deactivate.guard.ts │ │ └── routes.ts │ ├── customers │ │ ├── customers-card │ │ │ ├── customers-card.component.css │ │ │ ├── customers-card.component.html │ │ │ ├── customers-card.component.sandbox.ts │ │ │ └── customers-card.component.ts │ │ ├── customers-grid │ │ │ ├── customers-grid.component.css │ │ │ ├── customers-grid.component.html │ │ │ ├── customers-grid.component.sandbox.ts │ │ │ └── customers-grid.component.ts │ │ ├── customers.component.html │ │ ├── customers.component.sandbox.ts │ │ └── customers.component.ts │ ├── login │ │ ├── login.component.css │ │ ├── login.component.html │ │ └── login.component.ts │ ├── orders │ │ ├── orders.component.html │ │ └── orders.component.ts │ ├── routes.ts │ └── shared │ │ ├── directives │ │ └── sortby.directive.ts │ │ ├── filter-textbox │ │ ├── filter-textbox.component.css │ │ ├── filter-textbox.component.html │ │ └── filter-textbox.component.ts │ │ ├── interfaces.ts │ │ ├── map │ │ ├── map-point.component.ts │ │ ├── map.component.html │ │ └── map.component.ts │ │ ├── mocks.ts │ │ ├── pagination │ │ ├── pagination.component.css │ │ ├── pagination.component.html │ │ └── pagination.component.ts │ │ ├── pipes │ │ ├── capitalize.pipe.ts │ │ └── trim.pipe.ts │ │ └── router.animations.ts ├── index.html ├── main.ts ├── stories │ ├── About.stories.ts │ ├── Button.stories.ts │ ├── Customers-card.stories.ts │ ├── Header.stories.ts │ ├── Introduction.stories.mdx │ ├── Page.stories.ts │ ├── User.ts │ ├── assets │ │ ├── code-brackets.svg │ │ ├── colors.svg │ │ ├── comments.svg │ │ ├── direction.svg │ │ ├── flow.svg │ │ ├── plugin.svg │ │ ├── repo.svg │ │ └── stackalt.svg │ ├── button.component.ts │ ├── button.css │ ├── header.component.ts │ ├── header.css │ ├── page.component.ts │ └── page.css ├── styles.css ├── tsconfig.spec.json └── types │ └── importMeta.d.ts ├── tsconfig.app.json └── tsconfig.json /.angular-playground/.gitignore: -------------------------------------------------------------------------------- 1 | sandboxes.ts -------------------------------------------------------------------------------- /.angular-playground/angular-playground.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceRoots": ["./src"], 3 | "angularCli": { 4 | "appName": "playground" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.angular-playground/main.playground.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | import { PlaygroundModule } from 'angular-playground'; 3 | import { SandboxesDefined } from './sandboxes'; 4 | 5 | platformBrowserDynamic().bootstrapModule(PlaygroundModule 6 | .configure({ 7 | selector: 'cm-app-component', 8 | overlay: false, 9 | modules: [], 10 | sandboxesDefined: SandboxesDefined 11 | })) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /.angular-playground/tsconfig.playground.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "./main.playground.ts", 9 | "../src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "../src/**/*.d.ts", 13 | "../src/**/*.sandbox.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.205.2/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | RUN su node -c "npm install -g @angular/cli" 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.205.2/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local on arm64/Apple Silicon. 10 | "args": { 11 | "VARIANT": "16-bullseye" 12 | } 13 | }, 14 | 15 | // Set *default* container specific settings.json values on container create. 16 | "settings": {}, 17 | 18 | 19 | // Add the IDs of extensions you want installed when the container is created. 20 | "extensions": [ 21 | "dbaeumer.vscode-eslint" 22 | ], 23 | 24 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 25 | // "forwardPorts": [], 26 | 27 | // Use 'postCreateCommand' to run commands after the container is created. 28 | // "postCreateCommand": "yarn install", 29 | 30 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 31 | "remoteUser": "node" 32 | } -------------------------------------------------------------------------------- /.docker/config/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 0.0.0.0:80; 3 | listen [::]:80; 4 | default_type application/octet-stream; 5 | 6 | gzip on; 7 | gzip_comp_level 6; 8 | gzip_vary on; 9 | gzip_min_length 1000; 10 | gzip_proxied any; 11 | gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 12 | gzip_buffers 16 8k; 13 | client_max_body_size 256M; 14 | 15 | root /usr/share/nginx/html; 16 | 17 | location / { 18 | try_files $uri $uri/ /index.html =404; 19 | } 20 | } -------------------------------------------------------------------------------- /.docker/nginx.dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | VOLUME /var/cache/nginx 3 | COPY ./dist /usr/share/nginx/html 4 | COPY ./.docker/config/nginx.conf /etc/nginx/conf.d/default.conf 5 | 6 | # docker build -t nginx-angular -f nginx.dockerfile . 7 | # docker run -p 80:80 nginx-angular -------------------------------------------------------------------------------- /.docker/nginx.dockerfile: -------------------------------------------------------------------------------- 1 | ##### Stage 1 2 | FROM node:22 AS node 3 | LABEL author="Dan Wahlin" 4 | 5 | ARG NG_APP_API_URL 6 | ENV NG_APP_API_URL=$NG_APP_API_URL 7 | 8 | WORKDIR /app 9 | COPY package.json package-lock.json ./ 10 | RUN npm install 11 | COPY . . 12 | RUN npm run build 13 | 14 | ##### Stage 2 15 | FROM nginx:alpine 16 | VOLUME /var/cache/nginx 17 | COPY --from=node /app/dist/angular-jumpstart /usr/share/nginx/html 18 | COPY ./.docker/config/nginx.conf /etc/nginx/conf.d/default.conf 19 | 20 | # Run from project root 21 | # docker build -t nginx-angular -f .docker/nginx.dockerfile . 22 | # docker run -p 80:80 nginx-angular -------------------------------------------------------------------------------- /.docker/node.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | LABEL author="Dan Wahlin" 4 | 5 | ENV CONTAINER=true 6 | 7 | WORKDIR /var/www/node-service 8 | 9 | COPY package.json package-lock.json ./ 10 | RUN npm install --omit=dev --omit=optional 11 | 12 | COPY ./server.js . 13 | COPY ./public ./public 14 | 15 | EXPOSE 8080 16 | 17 | ENTRYPOINT ["node", "server.js"] -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/workflows/angular-jumpstart-api-AutoDeployTrigger-aadaf67b-44be-4e1b-952f-e7688098b8cb.yml: -------------------------------------------------------------------------------- 1 | name: Trigger auto deployment for angular-jumpstart-api 2 | 3 | # When this action will be executed 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: [opened, synchronize, reopened, closed] 10 | branches: 11 | - main 12 | 13 | # Allow mannually trigger 14 | workflow_dispatch: 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout to the branch 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v1 26 | 27 | - name: Log in to container registry 28 | uses: docker/login-action@v1 29 | with: 30 | registry: docker.io 31 | username: ${{ secrets.ANGULARJUMPSTARTAPI_REGISTRY_USERNAME }} 32 | password: ${{ secrets.ANGULARJUMPSTARTAPI_REGISTRY_PASSWORD }} 33 | 34 | - name: Build and push container image to registry 35 | uses: docker/build-push-action@v2 36 | with: 37 | push: true 38 | tags: danwahlin/node-service-jumpstart:${{ github.sha }} 39 | file: ./.docker/node.dockerfile 40 | context: ./ 41 | 42 | 43 | deploy: 44 | runs-on: ubuntu-latest 45 | needs: build 46 | 47 | steps: 48 | - name: Azure Login 49 | uses: azure/login@v1 50 | with: 51 | creds: ${{ secrets.ANGULARJUMPSTARTAPI_AZURE_CREDENTIALS }} 52 | 53 | 54 | - name: Deploy to containerapp 55 | uses: azure/CLI@v1 56 | with: 57 | inlineScript: | 58 | az config set extension.use_dynamic_install=yes_without_prompt 59 | az containerapp registry set -n angular-jumpstart-api -g Angular-Jumpstart-RG --server docker.io --username ${{ secrets.ANGULARJUMPSTARTAPI_REGISTRY_USERNAME }} --password ${{ secrets.ANGULARJUMPSTARTAPI_REGISTRY_PASSWORD }} 60 | az containerapp update -n angular-jumpstart-api -g Angular-Jumpstart-RG --image danwahlin/node-service-jumpstart:${{ github.sha }} 61 | -------------------------------------------------------------------------------- /.github/workflows/angular-jumpstart-ui-AutoDeployTrigger-2ef119d3-60ef-4c90-b01b-a766e68c5778.yml: -------------------------------------------------------------------------------- 1 | name: Trigger auto deployment for angular-jumpstart-ui 2 | 3 | # When this action will be executed 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: [opened, synchronize, reopened, closed] 10 | branches: 11 | - main 12 | 13 | # Allow mannually trigger 14 | workflow_dispatch: 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout to the branch 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v1 26 | 27 | - name: Log in to container registry 28 | uses: docker/login-action@v1 29 | with: 30 | registry: docker.io 31 | username: ${{ secrets.ANGULARJUMPSTARTUI_REGISTRY_USERNAME }} 32 | password: ${{ secrets.ANGULARJUMPSTARTUI_REGISTRY_PASSWORD }} 33 | 34 | - name: Build and push container image to registry 35 | uses: docker/build-push-action@v2 36 | with: 37 | push: true 38 | tags: danwahlin/nginx-angular-jumpstart:${{ github.sha }} 39 | build-args: NG_APP_API_URL=${{ secrets.NG_APP_API_URL }} 40 | file: ./.docker/nginx.dockerfile 41 | context: ./ 42 | 43 | deploy: 44 | runs-on: ubuntu-latest 45 | needs: build 46 | 47 | steps: 48 | - name: Azure Login 49 | uses: azure/login@v1 50 | with: 51 | creds: ${{ secrets.ANGULARJUMPSTARTUI_AZURE_CREDENTIALS }} 52 | 53 | 54 | - name: Deploy to containerapp 55 | uses: azure/CLI@v1 56 | with: 57 | inlineScript: | 58 | az config set extension.use_dynamic_install=yes_without_prompt 59 | az containerapp registry set -n angular-jumpstart-ui -g Angular-Jumpstart-RG --server docker.io --username ${{ secrets.ANGULARJUMPSTARTUI_REGISTRY_USERNAME }} --password ${{ secrets.ANGULARJUMPSTARTUI_REGISTRY_PASSWORD }} 60 | az containerapp update -n angular-jumpstart-ui -g Angular-Jumpstart-RG --image danwahlin/nginx-angular-jumpstart:${{ github.sha }} 61 | -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-jolly-sand-0e24e951e.yml: -------------------------------------------------------------------------------- 1 | name: Azure Static Web Apps CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build_and_deploy_job: 14 | if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | runs-on: ubuntu-latest 16 | name: Build and Deploy Job 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Build And Deploy 20 | id: builddeploy 21 | uses: Azure/static-web-apps-deploy@v0.0.1-preview 22 | with: 23 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_JOLLY_SAND_0E24E951E }} 24 | repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) 25 | action: 'upload' 26 | ###### Repository/Build Configurations - These values can be configured to match you app requirements. ###### 27 | app_location: '/' # App source code path 28 | api_location: 'api' # Api source code path - optional 29 | app_artifact_location: 'dist' # Built app content directory - optional 30 | ###### End of Repository/Build Configurations ###### 31 | 32 | close_pull_request_job: 33 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 34 | runs-on: ubuntu-latest 35 | name: Close Pull Request Job 36 | steps: 37 | - name: Close Pull Request 38 | id: closepullrequest 39 | uses: Azure/static-web-apps-deploy@v0.0.1-preview 40 | with: 41 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_JOLLY_SAND_0E24E951E }} 42 | action: 'close' 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.angular/cache 2 | # NPM 3 | node_modules 4 | npm-debug.log 5 | sandboxes.ts 6 | .angular 7 | 8 | # Code Editor Settings 9 | .idea 10 | .vs 11 | .env 12 | 13 | # Build Generated Files 14 | src/app/**/*.js 15 | src/app/**/*.js.map 16 | 17 | # Ignore any Webpack generated files 18 | dist 19 | src/app/devDist 20 | src/app/dist 21 | 22 | cypress/videos/ 23 | cypress/screenshots/ 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.k8s/nginx.deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: nginx 6 | name: nginx 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: nginx 12 | template: 13 | metadata: 14 | labels: 15 | app: nginx 16 | spec: 17 | containers: 18 | - image: danwahlin/nginx-angular-jumpstart:latest 19 | imagePullPolicy: IfNotPresent 20 | name: nginx 21 | ports: 22 | - containerPort: 80 23 | - containerPort: 443 24 | resources: {} 25 | -------------------------------------------------------------------------------- /.k8s/nginx.service.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: nginx 5 | labels: 6 | app: nginx 7 | spec: 8 | selector: 9 | app: nginx 10 | type: LoadBalancer 11 | ports: 12 | - name: "80" 13 | port: 80 14 | targetPort: 80 15 | - name: "443" 16 | port: 443 17 | targetPort: 443 18 | -------------------------------------------------------------------------------- /.k8s/node.deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | labels: 5 | app: node-env 6 | name: env-vars 7 | data: 8 | NODE_ENV: "production" 9 | CONTAINER: "true" 10 | --- 11 | 12 | apiVersion: apps/v1 13 | kind: Deployment 14 | metadata: 15 | labels: 16 | app: node 17 | name: node 18 | spec: 19 | replicas: 1 20 | selector: 21 | matchLabels: 22 | app: node 23 | template: 24 | metadata: 25 | labels: 26 | app: node 27 | spec: 28 | containers: 29 | - env: 30 | - name: NODE_ENV 31 | valueFrom: 32 | configMapKeyRef: 33 | key: NODE_ENV 34 | name: env-vars 35 | - name: CONTAINER 36 | valueFrom: 37 | configMapKeyRef: 38 | key: CONTAINER 39 | name: env-vars 40 | image: danwahlin/node-service-jumpstart:latest 41 | imagePullPolicy: IfNotPresent 42 | name: node-service-jumpstart 43 | ports: 44 | - containerPort: 8080 45 | resources: {} 46 | -------------------------------------------------------------------------------- /.k8s/node.service.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: node 5 | labels: 6 | app: node 7 | spec: 8 | selector: 9 | app: node 10 | type: LoadBalancer 11 | ports: 12 | - name: "8080" 13 | port: 8080 14 | targetPort: 8080 15 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials" 9 | ], 10 | "framework": "@storybook/angular", 11 | "core": { 12 | "builder": "webpack5" 13 | } 14 | } -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { setCompodocJson } from "@storybook/addon-docs/angular"; 2 | import docJson from "../documentation.json"; 3 | setCompodocJson(docJson); 4 | 5 | export const parameters = { 6 | actions: { argTypesRegex: "^on[A-Z].*" }, 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/, 11 | }, 12 | }, 13 | docs: { inlineStories: true }, 14 | } -------------------------------------------------------------------------------- /.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.app.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "node" 6 | ] 7 | }, 8 | "exclude": [ 9 | "../src/test.ts", 10 | "../src/**/*.spec.ts", 11 | "../projects/**/*.spec.ts" 12 | ], 13 | "include": [ 14 | "../src/**/*", 15 | "../projects/**/*" 16 | ], 17 | "files": [ 18 | "./typings.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.storybook/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Node Functions", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 9229, 9 | "preLaunchTask": "func: host start" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/app/**/*.js.map": true, 4 | "**/app/**/*.js": true, 5 | 6 | "**/config/*.js.map": true, 7 | "**/config/*.js": { 8 | "when": "$(basename).js.map" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "api", 3 | "azureFunctions.postDeployTask": "npm install", 4 | "azureFunctions.projectLanguage": "JavaScript", 5 | "azureFunctions.projectRuntime": "~2", 6 | "debug.internalConsoleOptions": "neverOpen", 7 | "azureFunctions.preDeployTask": "npm prune" 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "command": "host start", 7 | "problemMatcher": "$func-watch", 8 | "isBackground": true, 9 | "dependsOn": "npm install", 10 | "options": { 11 | "cwd": "${workspaceFolder}/api" 12 | } 13 | }, 14 | { 15 | "type": "shell", 16 | "label": "npm install", 17 | "command": "npm install", 18 | "options": { 19 | "cwd": "${workspaceFolder}/api" 20 | } 21 | }, 22 | { 23 | "type": "shell", 24 | "label": "npm prune", 25 | "command": "npm prune --production", 26 | "problemMatcher": [], 27 | "options": { 28 | "cwd": "${workspaceFolder}/api" 29 | } 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dan Wahlin and Wahlin Consulting (http://github.com/DanWahlin) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-jumpstart": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@ngx-env/builder:browser", 15 | "options": { 16 | "outputPath": "dist/angular-jumpstart", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": [ 20 | "zone.js" 21 | ], 22 | "tsConfig": "tsconfig.app.json", 23 | "assets": [ 24 | { 25 | "glob": "**/*", 26 | "input": "public" 27 | } 28 | ], 29 | "styles": [ 30 | "src/styles.css" 31 | ], 32 | "scripts": [] 33 | }, 34 | "configurations": { 35 | "production": { 36 | "budgets": [ 37 | { 38 | "type": "initial", 39 | "maximumWarning": "500kB", 40 | "maximumError": "1MB" 41 | }, 42 | { 43 | "type": "anyComponentStyle", 44 | "maximumWarning": "2kB", 45 | "maximumError": "4kB" 46 | } 47 | ], 48 | "outputHashing": "all" 49 | }, 50 | "development": { 51 | "optimization": false, 52 | "extractLicenses": false, 53 | "sourceMap": true 54 | } 55 | }, 56 | "defaultConfiguration": "production" 57 | }, 58 | "serve": { 59 | "builder": "@ngx-env/builder:dev-server", 60 | "configurations": { 61 | "production": { 62 | "buildTarget": "angular-jumpstart:build:production" 63 | }, 64 | "development": { 65 | "buildTarget": "angular-jumpstart:build:development" 66 | } 67 | }, 68 | "defaultConfiguration": "development" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /api/.funcignore: -------------------------------------------------------------------------------- 1 | *.js.map 2 | *.ts 3 | .git* 4 | .vscode 5 | local.settings.json 6 | test 7 | tsconfig.json -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | 86 | # TypeScript output 87 | dist 88 | out 89 | 90 | # Azure Functions artifacts 91 | bin 92 | obj 93 | appsettings.json 94 | local.settings.json -------------------------------------------------------------------------------- /api/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Node Functions", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 9229, 9 | "preLaunchTask": "func: host start" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /api/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "command": "host start", 7 | "problemMatcher": "$func-watch", 8 | "isBackground": true, 9 | "dependsOn": "npm install" 10 | }, 11 | { 12 | "type": "shell", 13 | "label": "npm install", 14 | "command": "npm install" 15 | }, 16 | { 17 | "type": "shell", 18 | "label": "npm prune", 19 | "command": "npm prune --production", 20 | "problemMatcher": [] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /api/CustomerById/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "customers/{id}" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/CustomerById/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | 3 | module.exports = async function (context, req) { 4 | const { id } = context.bindingData; 5 | let customerId = +id; 6 | let selectedCustomer = customers.find(customer => customer.id === customerId); 7 | let response = selectedCustomer ? selectedCustomer : null; 8 | 9 | context.res = { 10 | // status: 200, /* Defaults to 200 */ 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: response 15 | }; 16 | } -------------------------------------------------------------------------------- /api/CustomerCreate/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "post" 10 | ], 11 | "route": "customers" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/CustomerCreate/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | 3 | module.exports = async function (context, req) { 4 | let postedCustomer = req.body; 5 | let maxId = Math.max.apply(Math, customers.map((cust) => cust.id)); 6 | postedCustomer.id = ++maxId; 7 | postedCustomer.gender = (postedCustomer.id % 2 === 0) ? 'female' : 'male'; 8 | customers.push(postedCustomer); 9 | 10 | context.res = { 11 | // status: 200, /* Defaults to 200 */ 12 | headers: { 13 | 'Content-Type': 'application/json' 14 | }, 15 | body: postedCustomer 16 | }; 17 | } -------------------------------------------------------------------------------- /api/CustomerDelete/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "delete" 10 | ], 11 | "route": "customers/{id}" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/CustomerDelete/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | 3 | module.exports = async function (context, req) { 4 | const { id } = context.bindingData; 5 | 6 | let customerId = +id; 7 | for (let i = 0, len = customers.length; i < len; i++) { 8 | if (customers[i].id === customerId) { 9 | customers.splice(i, 1); 10 | break; 11 | } 12 | } 13 | 14 | 15 | context.res = { 16 | headers: { 17 | 'Content-Type': 'application/json' 18 | }, 19 | // status: 200, /* Defaults to 200 */ 20 | body: { 21 | status: true 22 | } 23 | }; 24 | } -------------------------------------------------------------------------------- /api/CustomerUpdate/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "put" 10 | ], 11 | "route": "customers/{id}" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/CustomerUpdate/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | const states = require('../data/states.json'); 3 | 4 | module.exports = async function (context, req) { 5 | let putCustomer = req.body; 6 | let id = +req.params.id; 7 | let status = false; 8 | 9 | //Ensure state name is in sync with state abbreviation 10 | const filteredStates = states.filter((state) => state.abbreviation === putCustomer.state.abbreviation); 11 | if (filteredStates && filteredStates.length) { 12 | putCustomer.state.name = filteredStates[0].name; 13 | console.log('Updated putCustomer state to ' + putCustomer.state.name); 14 | } 15 | 16 | for (let i = 0, len = customers.length; i < len; i++) { 17 | if (customers[i].id === id) { 18 | customers[i] = putCustomer; 19 | status = true; 20 | break; 21 | } 22 | } 23 | 24 | context.res = { 25 | headers : { 26 | 'Content-Type': 'application/json' 27 | }, 28 | // status: 200, /* Defaults to 200 */ 29 | body: { 30 | status: status 31 | } 32 | }; 33 | } -------------------------------------------------------------------------------- /api/CustomersAll/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "customers" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/CustomersAll/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | 3 | module.exports = async function (context, req) { 4 | context.res = { 5 | headers: { 6 | 'Content-Type': 'application/json' 7 | }, 8 | // status: 200, /* Defaults to 200 */ 9 | body: customers 10 | }; 11 | } -------------------------------------------------------------------------------- /api/CustomersPage/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "customers/page/{skip}/{top}" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/CustomersPage/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | 3 | module.exports = async function (context, req) { 4 | const { skip: skipVal, top: topVal } = context.bindingData; 5 | const skip = (isNaN(skipVal)) ? 0 : +skipVal; 6 | let top = (isNaN(topVal)) ? 10 : skip + (+topVal); 7 | 8 | if (top > customers.length) { 9 | top = skip + (customers.length - skip); 10 | } 11 | 12 | console.log(`Skip: ${skip} Top: ${top}`); 13 | 14 | var pagedCustomers = customers.slice(skip, top); 15 | 16 | context.res = { 17 | // status: 200, /* Defaults to 200 */ 18 | headers : { 19 | 'Content-Type': 'application/json', 20 | 'X-InlineCount': customers.length 21 | }, 22 | body: pagedCustomers 23 | }; 24 | } -------------------------------------------------------------------------------- /api/Login/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "post" 10 | ], 11 | "route": "auth/login" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/Login/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (context, req) { 2 | const userLogin = req.body; 3 | 4 | context.res = { 5 | headers: { 6 | 'Content-Type': 'application/json' 7 | }, 8 | // status: 200, /* Defaults to 200 */ 9 | body: 'true' 10 | }; 11 | } -------------------------------------------------------------------------------- /api/Logout/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "post" 10 | ], 11 | "route": "auth/logout" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/Logout/index.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (context, req) { 2 | 3 | context.res = { 4 | headers: { 5 | 'Content-Type': 'application/json' 6 | }, 7 | // status: 200, /* Defaults to 200 */ 8 | body: 'true' 9 | }; 10 | } -------------------------------------------------------------------------------- /api/OrderById/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "orders/{id}" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/OrderById/index.js: -------------------------------------------------------------------------------- 1 | const customers = require('../data/customers.json'); 2 | 3 | module.exports = async function (context, req) { 4 | const { id } = context.bindingData; 5 | let customerId = +id; 6 | 7 | const foundCustomer = customers.find(customer => customer.id === id); 8 | 9 | context.res = { 10 | // status: 200, /* Defaults to 200 */ 11 | body: foundCustomer ? foundCustomer: [] 12 | }; 13 | } -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | ## Install dependencies 2 | 3 | ```bash 4 | https://github.com/Azure/azure-functions-core-tools 5 | ``` 6 | 7 | and the VS Code extension called `Azure Functions` 8 | 9 | ## Run 10 | 11 | Run either with `Run/Start Debugging` 12 | 13 | or `npm start` or `func host start` from the command line 14 | 15 | ## Interesting parts 16 | 17 | A Function app normally runs on URL `http://localhost:7071/api/` 18 | 19 | - `host.json`, this sets the `routePrefix` to empty string which means that the base URL is now `http://localhost:7071/` 20 | 21 | Every function is built up of a directory looking like so: 22 | 23 | ```bash 24 | --| 25 | ----| function.json 26 | ----| index.js 27 | ``` 28 | 29 | - `function.json`, this the configuration that set's up the function 30 | - `authLevel`, this decided whether the user needs an API key or not, possible values `anonymous`, `function`, `admin` 31 | - `type`, how the function is triggered, can be many things like a queue message a DB row etc, in this case it's a `httpTrigger` 32 | - `direction`, this says whether this is incoming data or outgoing. We can for example have an incoming HTTP message but an outgoing DB row (we write to a DB). Read more about this on bindings 33 | - `name`, name of the request/response object depending on 34 | - `methods`, this is an array of supported methods 35 | - `route`, this sets up what routing pattern we respond on -------------------------------------------------------------------------------- /api/StatesAll/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "states" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /api/StatesAll/index.js: -------------------------------------------------------------------------------- 1 | const states = require('../data/states.json'); 2 | 3 | module.exports = async function (context, req) { 4 | context.res = { 5 | headers: { 6 | 'Content-Type': 'application/json' 7 | }, 8 | // status: 200, /* Defaults to 200 */ 9 | body: states 10 | }; 11 | } -------------------------------------------------------------------------------- /api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensionBundle": { 4 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 5 | "version": "[1.*, 2.0.0)" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /api/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "func start", 7 | "test": "echo \"No tests yet...\"" 8 | }, 9 | "dependencies": {}, 10 | "devDependencies": {} 11 | } 12 | -------------------------------------------------------------------------------- /api/proxies.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/proxies", 3 | "proxies": {} 4 | } 5 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | component: { 5 | devServer: { 6 | framework: "angular", 7 | bundler: "webpack", 8 | }, 9 | specPattern: "**/*.cy.ts", 10 | }, 11 | e2e: { 12 | baseUrl: 'http://localhost:8080', 13 | setupNodeEvents(on, config) { 14 | // implement node event listeners here 15 | }, 16 | }, 17 | viewportWidth: 1024, 18 | viewportHeight: 768, 19 | chromeWebSecurity: false 20 | }); 21 | -------------------------------------------------------------------------------- /cypress/e2e/customers.spec.cy.ts: -------------------------------------------------------------------------------- 1 | 2 | describe("Customers", () => { 3 | beforeEach(() => { 4 | cy.visit("/customers"); 5 | }); 6 | 7 | it("should display 10 customers", () => { 8 | cy.get('.card').should('have.length', 10); 9 | }); 10 | 11 | it("should filter and display 10 customers", () => { 12 | cy.wait(200); // pause to let cards load 13 | cy.get('[name="filter"]').type('ze'); 14 | cy.get('.card').should('have.length', 1); 15 | }); 16 | 17 | it("should navigate to page 3", () => { 18 | cy.get('.pagination > :nth-child(4) > a').click(); 19 | cy.get('.card').should('have.length', 2); 20 | }); 21 | 22 | it("should display list view", () => { 23 | // Click List View 24 | cy.get('.navbar > .nav > :nth-child(2) > a').click(); 25 | cy.get('tr').should('have.length.gt', 5); 26 | }); 27 | 28 | it("should display map view", () => { 29 | // Click Map View 30 | cy.get('.navbar > .nav > :nth-child(3) > a').click(); 31 | cy.get('cm-map').should('exist'); 32 | }); 33 | 34 | it("should click New Customer and navigate to login", () => { 35 | // Click New Customer 36 | cy.get('.navbar > .nav > :nth-child(4) > a').click(); 37 | cy.url().should('include', '/login'); 38 | }); 39 | 40 | 41 | }); -------------------------------------------------------------------------------- /cypress/e2e/login.spec.cy.ts: -------------------------------------------------------------------------------- 1 | describe("Login", () => { 2 | beforeEach(() => { 3 | // If we're logged in ensure we log out 4 | cy.visit("/"); 5 | cy.get('[data-cy="login-logout"]').click(); 6 | cy.visit("/login"); 7 | }); 8 | 9 | it("should login user", () => { 10 | cy.get('[name="email"]').type('test@test.com'); 11 | cy.get('[name="password').type('password1'); 12 | cy.get('.btn-success').click(); 13 | cy.url().should('include', '/customers'); 14 | }); 15 | 16 | it("should show errors with invalid email or password", () => { 17 | const emailSelector = '[name="email'; 18 | const passwordSelector = '[name="password"]'; 19 | cy.get(emailSelector).type('test'); 20 | cy.get(emailSelector).blur(); 21 | cy.get('[data-cy="email-error"]').should('contain', 'A valid email address is required'); 22 | cy.get(passwordSelector).type('pwd'); 23 | cy.get(passwordSelector).blur(); 24 | cy.get('[data-cy="password-error"]').should('contain', 'Password is required'); 25 | }); 26 | 27 | }); -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [ 4 | "../node_modules/cypress", 5 | "*/*.ts" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Run docker-compose build 2 | # Run docker-compose up 3 | # Visit http://localhost 4 | # Live long and prosper 5 | 6 | services: 7 | 8 | nginx: 9 | container_name: nginx-angular-jumpstart 10 | image: danwahlin/nginx-angular-jumpstart:latest 11 | build: 12 | context: . 13 | dockerfile: .docker/nginx.dockerfile 14 | ports: 15 | - "80:80" 16 | - "443:443" 17 | depends_on: 18 | - node 19 | networks: 20 | - app-network 21 | platform: linux/amd64 22 | 23 | node: 24 | container_name: node-service-jumpstart 25 | image: danwahlin/node-service-jumpstart:latest 26 | build: 27 | context: . 28 | dockerfile: .docker/node.dockerfile 29 | environment: 30 | - NODE_ENV=production 31 | - CONTAINER=true 32 | ports: 33 | - "8080:8080" 34 | networks: 35 | - app-network 36 | platform: linux/amd64 37 | 38 | # Can uncomment to get cAdvisor going on Mac/Linux. Not for Windows though. 39 | # cadvisor: 40 | # container_name: cadvisor 41 | # image: google/cadvisor 42 | # volumes: 43 | # - /:/rootfs:ro 44 | # - /var/run:/var/run:rw 45 | # - /sys:/sys:ro 46 | # - /var/lib/docker/:/var/lib/docker:ro 47 | # ports: 48 | # - "9000:8080" 49 | # networks: 50 | # - app-network 51 | 52 | networks: 53 | app-network: 54 | driver: bridge -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-jump-start", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "node server.js", 9 | "build": "ng build angular-jumpstart", 10 | "cypress": "concurrently \"npm start\" \"npx cypress open\"", 11 | "cypress:headless": "concurrently \"npm start\" \"npx cypress run\"", 12 | "playground": "angular-playground", 13 | "docs:json": "compodoc -p ./tsconfig.json -e json -d .", 14 | "storybook": "npm run docs:json && start-storybook -p 6006", 15 | "build-storybook": "npm run docs:json && build-storybook" 16 | }, 17 | "dependencies": { 18 | "@angular/animations": "^18.2.0", 19 | "@angular/common": "^18.2.0", 20 | "@angular/compiler": "^18.2.0", 21 | "@angular/core": "^18.2.0", 22 | "@angular/forms": "^18.2.0", 23 | "@angular/platform-browser": "^18.2.0", 24 | "@angular/platform-browser-dynamic": "^18.2.0", 25 | "@angular/router": "^18.2.0", 26 | "@ngx-env/builder": "^18.0.2", 27 | "express": "^4.21.1", 28 | "rxjs": "~7.8.0", 29 | "tslib": "^2.3.0", 30 | "zone.js": "~0.14.10" 31 | }, 32 | "devDependencies": { 33 | "@angular-devkit/build-angular": "^18.2.12", 34 | "@angular/cli": "^18.2.12", 35 | "@angular/compiler-cli": "^18.2.0", 36 | "@types/google.maps": "^3.58.1", 37 | "open": "^10.1.0", 38 | "typescript": "~5.5.2" 39 | }, 40 | "optionalDependencies": { 41 | "cypress": "^13.15.2" 42 | } 43 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/favicon.ico -------------------------------------------------------------------------------- /public/images/female.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/female.png -------------------------------------------------------------------------------- /public/images/male.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/male.png -------------------------------------------------------------------------------- /public/images/people.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/people.png -------------------------------------------------------------------------------- /public/images/screenshots/cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/screenshots/cards.png -------------------------------------------------------------------------------- /public/images/screenshots/details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/screenshots/details.png -------------------------------------------------------------------------------- /public/images/screenshots/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/screenshots/grid.png -------------------------------------------------------------------------------- /public/images/screenshots/orders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/screenshots/orders.png -------------------------------------------------------------------------------- /public/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/public/images/spinner.gif -------------------------------------------------------------------------------- /public/staticwebapp.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "navigationFallback": { 3 | "rewrite": "/index.html" 4 | } 5 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | // Resolve __dirname and __filename for ESM 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const app = express(); 11 | const port = process.env.PORT || 8080; 12 | const inContainer = process.env.CONTAINER; 13 | const inAzure = process.env.WEBSITE_RESOURCE_GROUP; 14 | 15 | console.log('inContainer', inContainer); 16 | console.log('inAzure', inAzure); 17 | console.log('__dirname', __dirname); 18 | 19 | // Load data from JSON files 20 | const customers = JSON.parse(fs.readFileSync(path.join(__dirname, 'public/data/customers.json'), 'utf-8')); 21 | const states = JSON.parse(fs.readFileSync(path.join(__dirname, 'public/data/states.json'), 'utf-8')); 22 | 23 | // Middleware for parsing request bodies 24 | app.use(express.urlencoded({ extended: true })); 25 | app.use(express.json()); 26 | 27 | // CORS middleware 28 | app.use((req, res, next) => { 29 | res.header('Access-Control-Allow-Origin', '*'); 30 | res.header('Access-Control-Allow-Credentials', true); 31 | res.header( 32 | 'Access-Control-Allow-Headers', 33 | 'Origin, Authorization, X-Requested-With, X-XSRF-TOKEN, X-InlineCount, Content-Type, Accept' 34 | ); 35 | res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,PATCH,OPTIONS'); 36 | next(); 37 | }); 38 | 39 | // Serve static files if not running in a container 40 | if (!inContainer) { 41 | app.use(express.static(path.join(__dirname, 'dist/angular-jumpstart'))); 42 | console.log(`Static files served from ${path.join(__dirname, 'dist/angular-jumpstart')}`); 43 | } 44 | 45 | // Helper function to safely fetch a customer by ID 46 | const getCustomerById = (id) => customers.find((customer) => customer.id === id); 47 | 48 | // Routes 49 | app.get('/api/customers/page/:skip/:top', (req, res) => { 50 | const skip = parseInt(req.params.skip, 10) || 0; 51 | const top = parseInt(req.params.top, 10) || 10; 52 | 53 | const pagedCustomers = customers.slice(skip, skip + top); 54 | res.setHeader('X-InlineCount', customers.length); 55 | res.json(pagedCustomers); 56 | }); 57 | 58 | app.get('/api/customers', (req, res) => res.json(customers)); 59 | 60 | app.get('/api/customers/:id', (req, res) => { 61 | const customerId = parseInt(req.params.id, 10); 62 | const customer = getCustomerById(customerId); 63 | res.json(customer || {}); 64 | }); 65 | 66 | app.post('/api/customers', (req, res) => { 67 | const newCustomer = req.body; 68 | newCustomer.id = Math.max(...customers.map((cust) => cust.id), 0) + 1; 69 | newCustomer.gender = newCustomer.id % 2 === 0 ? 'female' : 'male'; 70 | customers.push(newCustomer); 71 | res.json(newCustomer); 72 | }); 73 | 74 | app.put('/api/customers/:id', (req, res) => { 75 | const customerId = parseInt(req.params.id, 10); 76 | const updatedCustomer = req.body; 77 | 78 | const stateMatch = states.find((state) => state.abbreviation === updatedCustomer.state?.abbreviation); 79 | if (stateMatch) { 80 | updatedCustomer.state.name = stateMatch.name; 81 | } 82 | 83 | const index = customers.findIndex((cust) => cust.id === customerId); 84 | if (index !== -1) { 85 | customers[index] = updatedCustomer; 86 | res.json({ status: true }); 87 | } else { 88 | res.json({ status: false }); 89 | } 90 | }); 91 | 92 | app.delete('/api/customers/:id', (req, res) => { 93 | const customerId = parseInt(req.params.id, 10); 94 | const index = customers.findIndex((cust) => cust.id === customerId); 95 | if (index !== -1) { 96 | customers.splice(index, 1); 97 | res.json({ status: true }); 98 | } else { 99 | res.json({ status: false }); 100 | } 101 | }); 102 | 103 | app.get('/api/orders/:id', (req, res) => { 104 | const customerId = parseInt(req.params.id, 10); 105 | const orders = customers.find((cust) => cust.customerId === customerId)?.orders || []; 106 | res.json(orders); 107 | }); 108 | 109 | app.get('/api/states', (req, res) => res.json(states)); 110 | 111 | app.post('/api/auth/login', (req, res) => res.json(true)); // Simulate login 112 | app.post('/api/auth/logout', (req, res) => res.json(true)); // Simulate logout 113 | 114 | // Catch-all route for HTML5 history 115 | if (!inContainer) { 116 | app.all('*', (req, res) => { 117 | res.sendFile(path.join(__dirname, 'dist/angular-jumpstart/index.html')); 118 | }); 119 | } 120 | 121 | // Start the server 122 | app.listen(port, () => { 123 | console.log(`Express server running on http://localhost:${port}`); 124 | }); 125 | 126 | // Open the browser (only if not in a container or Azure) 127 | if (!inContainer && !inAzure) { 128 | (async () => { 129 | const { default: open } = await import('open'); 130 | open(`http://localhost:${port}`); 131 | })(); 132 | } 133 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2beta4 2 | kind: Config 3 | metadata: 4 | name: angular-jumpstart 5 | build: 6 | artifacts: 7 | - image: nginx-angular-jumpstart 8 | context: . 9 | sync: 10 | # A local build will update dist and sync it to the container 11 | manual: 12 | - src: "./dist" 13 | dest: "/usr/share/nginx/html" 14 | docker: 15 | # Referencing a dev version of the Dockerfile so that the speed is better 16 | # Will build app locally which is then copied into the image 17 | # Much faster than doing a multi-stage build as you would for production 18 | dockerfile: .docker/nginx.dev.dockerfile 19 | - image: node-service-jumpstart 20 | context: . 21 | docker: 22 | dockerfile: .docker/node.dockerfile 23 | deploy: 24 | kubectl: 25 | manifests: 26 | - .k8s/*.yml 27 | -------------------------------------------------------------------------------- /src/app/about/about.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

About

5 |
6 |
7 |
8 |
9 |
Created by:
10 | 11 |
12 |
13 |
14 |
Blog:
15 | 16 |
17 |
18 | 22 |
23 |
24 |
-------------------------------------------------------------------------------- /src/app/about/about.component.sandbox.ts: -------------------------------------------------------------------------------- 1 | import { sandboxOf } from 'angular-playground'; 2 | import { AboutComponent } from './about.component'; 3 | 4 | const sandboxConfig = { 5 | imports: [AboutComponent] 6 | } 7 | 8 | export default sandboxOf(AboutComponent, sandboxConfig) 9 | .add('About Component', { 10 | template: `` 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'cm-about', 5 | templateUrl: './about.component.html', 6 | standalone: true 7 | }) 8 | export class AboutComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit() { 13 | 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DanWahlin/Angular-JumpStart/12d9e69a0994abed7974ac346be0f7c4a000fc6e/src/app/app.component.css -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 |    Loading 8 | 9 |
10 |

-------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { OverlayComponent } from './core/overlay/overlay.component'; 3 | import { ModalComponent } from './core/modal/modal.component'; 4 | import { GrowlerComponent } from './core/growler/growler.component'; 5 | import { RouterOutlet } from '@angular/router'; 6 | import { NavbarComponent } from './core/navbar/navbar.component'; 7 | 8 | @Component({ 9 | selector: 'cm-app-component', 10 | templateUrl: './app.component.html', 11 | standalone: true, 12 | imports: [NavbarComponent, RouterOutlet, GrowlerComponent, ModalComponent, OverlayComponent] 13 | }) 14 | export class AppComponent { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/app/core/growler/growler.component.css: -------------------------------------------------------------------------------- 1 | .growler { 2 | position: fixed; 3 | z-index: 999999; 4 | } 5 | 6 | .growler.close-button:focus { 7 | outline: 0; 8 | } 9 | 10 | .growler.top-left { 11 | top: 12px; 12 | left: 12px; 13 | } 14 | 15 | .growler.top-right { 16 | top: 12px; 17 | right: 12px; 18 | } 19 | 20 | .growler.bottom-right { 21 | bottom: 12px; 22 | right: 12px; 23 | } 24 | 25 | .growler.bottom-left { 26 | bottom: 12px; 27 | left: 12px; 28 | } 29 | 30 | .growler.top-center { 31 | top: 12px; 32 | left: 50%; 33 | -webkit-transform: translate(-50%, 0%); 34 | transform: translate(-50%, 0%); 35 | } 36 | 37 | .growler.bottom-center { 38 | bottom: 12px; 39 | left: 50%; 40 | -webkit-transform: translate(-50%, 0%); 41 | transform: translate(-50%, 0%); 42 | } 43 | 44 | .growl { 45 | cursor: pointer; 46 | padding: 5; 47 | width: 285px; 48 | height: 65px; 49 | opacity: 0; 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | 54 | -webkit-transition: opacity 1s; 55 | -moz-transition: opacity 1s; 56 | -o-transition: opacity 1s; 57 | transition: opacity 1s; 58 | } 59 | 60 | .growl.active { 61 | opacity: 1; 62 | } 63 | 64 | .growl-message { 65 | 66 | } -------------------------------------------------------------------------------- /src/app/core/growler/growler.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; 2 | 3 | import { GrowlerService, GrowlerMessageType } from './growler.service'; 4 | import { LoggerService } from '../services/logger.service'; 5 | import { NgClass, NgFor } from '@angular/common'; 6 | 7 | @Component({ 8 | selector: 'cm-growler', 9 | template: ` 10 |
11 |
13 | {{ growl.message }} 14 |
15 |
16 | `, 17 | styleUrls: ['growler.component.css'], 18 | standalone: true, 19 | imports: [NgClass, NgFor] 20 | }) 21 | export class GrowlerComponent implements OnInit { 22 | 23 | private growlCount = 0; 24 | growls: Growl[] = []; 25 | 26 | @Input() position = 'bottom-right'; 27 | @Input() timeout = 3000; 28 | 29 | constructor(private growlerService: GrowlerService, 30 | private logger: LoggerService) { 31 | growlerService.growl = this.growl.bind(this); 32 | } 33 | 34 | ngOnInit() { } 35 | 36 | /** 37 | * Displays a growl message. 38 | * 39 | * @param {string} message - The message to display. 40 | * @param {GrowlMessageType} growlType - The type of message to display (a GrowlMessageType enumeration) 41 | * @return {number} id - Returns the ID for the generated growl 42 | */ 43 | growl(message: string, growlType: GrowlerMessageType): number { 44 | this.growlCount++; 45 | const bootstrapAlertType = GrowlerMessageType[growlType].toLowerCase(); 46 | const messageType = `alert-${ bootstrapAlertType }`; 47 | 48 | const growl = new Growl(this.growlCount, message, messageType, this.timeout, this); 49 | this.growls.push(growl); 50 | return growl.id; 51 | } 52 | 53 | removeGrowl(id: number) { 54 | this.growls.forEach((growl: Growl, index: number) => { 55 | if (growl.id === id) { 56 | this.growls.splice(index, 1); 57 | this.growlCount--; 58 | this.logger.log('removed ' + id); 59 | } 60 | }); 61 | } 62 | } 63 | 64 | class Growl { 65 | 66 | enabled: boolean = false; 67 | timeoutId: number = 0; 68 | 69 | constructor(public id: number, 70 | public message: string, 71 | public messageType: string, 72 | private timeout: number, 73 | private growlerContainer: GrowlerComponent) { 74 | this.show(); 75 | } 76 | 77 | show() { 78 | window.setTimeout(() => { 79 | this.enabled = true; 80 | this.setTimeout(); 81 | }, 0); 82 | } 83 | 84 | setTimeout() { 85 | window.setTimeout(() => { 86 | this.hide(); 87 | }, this.timeout); 88 | } 89 | 90 | hide() { 91 | this.enabled = false; 92 | window.setTimeout(() => { 93 | this.growlerContainer.removeGrowl(this.id); 94 | }, this.timeout); 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/app/core/growler/growler.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class GrowlerService { 5 | 6 | constructor() { } 7 | 8 | growl: (message: string, growlType: GrowlerMessageType) => number = () => 0; 9 | 10 | } 11 | 12 | export enum GrowlerMessageType { 13 | Success, 14 | Danger, 15 | Warning, 16 | Info 17 | } 18 | -------------------------------------------------------------------------------- /src/app/core/interceptors/auth.interceptor.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpEvent, HttpInterceptor, HttpHandler, HttpRequest} from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class AuthInterceptor implements HttpInterceptor { 7 | constructor() {} 8 | 9 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 10 | // Get the auth header (fake value is shown here) 11 | const authHeader = '49a5kdkv409fd39'; // this.authService.getAuthHeader(); 12 | const authReq = req.clone({headers: req.headers.set('Authorization', authHeader)}); 13 | return next.handle(authReq); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/core/modal/modal.component.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | background: rgba(0,0,0,0.6); 3 | } -------------------------------------------------------------------------------- /src/app/core/modal/modal.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/core/modal/modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, EventEmitter } from '@angular/core'; 2 | 3 | import { ModalService, IModalContent } from './modal.service'; 4 | import { NgClass, NgStyle, NgIf } from '@angular/common'; 5 | 6 | @Component({ 7 | selector: 'cm-modal', 8 | templateUrl: './modal.component.html', 9 | styleUrls: ['./modal.component.css'], 10 | standalone: true, 11 | imports: [NgClass, NgStyle, NgIf] 12 | }) 13 | export class ModalComponent implements OnInit { 14 | 15 | modalVisible = false; 16 | modalVisibleAnimate = false; 17 | modalContent: IModalContent = {}; 18 | cancel: () => void = () => {}; 19 | ok: () => void = () => {}; 20 | defaultModalContent: IModalContent = { 21 | header: 'Please Confirm', 22 | body: 'Are you sure you want to continue?', 23 | cancelButtonText: 'Cancel', 24 | OKButtonText: 'OK', 25 | cancelButtonVisible: true 26 | }; 27 | 28 | constructor(private modalService: ModalService) { 29 | modalService.show = this.show.bind(this); 30 | modalService.hide = this.hide.bind(this); 31 | } 32 | 33 | ngOnInit() { 34 | 35 | } 36 | 37 | show(modalContent: IModalContent) { 38 | this.modalContent = Object.assign(this.defaultModalContent, modalContent); 39 | this.modalVisible = true; 40 | setTimeout(() => this.modalVisibleAnimate = true); 41 | 42 | const promise = new Promise((resolve, reject) => { 43 | this.cancel = () => { 44 | this.hide(); 45 | resolve(false); 46 | }; 47 | this.ok = () => { 48 | this.hide(); 49 | resolve(true); 50 | }; 51 | }); 52 | return promise; 53 | } 54 | 55 | hide() { 56 | this.modalVisibleAnimate = false; 57 | setTimeout(() => this.modalVisible = false, 300); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/app/core/modal/modal.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | export interface IModalContent { 4 | header?: string; 5 | body?: string; 6 | cancelButtonText?: string; 7 | OKButtonText?: string; 8 | cancelButtonVisible?: boolean; 9 | } 10 | 11 | @Injectable({ providedIn: 'root' }) 12 | export class ModalService { 13 | 14 | constructor() { } 15 | 16 | show: (modalContent: IModalContent) => Promise = () => { return {} as Promise; }; 17 | hide: () => void = () => {}; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/app/core/navbar/navbar.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/core/navbar/navbar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { Router, RouterLink, RouterLinkActive } from '@angular/router'; 3 | 4 | import { Subscription } from 'rxjs'; 5 | 6 | import { AuthService } from '../services/auth.service'; 7 | import { GrowlerService, GrowlerMessageType } from '../growler/growler.service'; 8 | import { LoggerService } from '../services/logger.service'; 9 | 10 | @Component({ 11 | selector: 'cm-navbar', 12 | templateUrl: './navbar.component.html', 13 | standalone: true, 14 | imports: [RouterLink, RouterLinkActive] 15 | }) 16 | export class NavbarComponent implements OnInit, OnDestroy { 17 | 18 | isCollapsed: boolean = false; 19 | loginLogoutText = 'Login'; 20 | sub: Subscription = {} as Subscription; 21 | 22 | constructor(private router: Router, 23 | private authservice: AuthService, 24 | private growler: GrowlerService, 25 | private logger: LoggerService) { } 26 | 27 | ngOnInit() { 28 | this.sub = this.authservice.authChanged 29 | .subscribe((loggedIn: boolean) => { 30 | this.setLoginLogoutText(); 31 | }, 32 | (err: any) => this.logger.log(err)); 33 | } 34 | 35 | ngOnDestroy() { 36 | this.sub.unsubscribe(); 37 | } 38 | 39 | loginOrOut() { 40 | const isAuthenticated = this.authservice.isAuthenticated; 41 | if (isAuthenticated) { 42 | this.authservice.logout() 43 | .subscribe((status: boolean) => { 44 | this.setLoginLogoutText(); 45 | this.growler.growl('Logged Out', GrowlerMessageType.Info); 46 | this.router.navigate(['/customers']); 47 | return; 48 | }, 49 | (err: any) => this.logger.log(err)); 50 | } 51 | this.redirectToLogin(); 52 | } 53 | 54 | redirectToLogin() { 55 | this.router.navigate(['/login']); 56 | } 57 | 58 | setLoginLogoutText() { 59 | this.loginLogoutText = (this.authservice.isAuthenticated) ? 'Logout' : 'Login'; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/app/core/overlay/overlay-request-response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http'; 3 | 4 | import { Observable, of } from 'rxjs'; 5 | import { tap, delay, catchError } from 'rxjs/operators'; 6 | 7 | import { EventBusService, EmitEvent, Events } from '../services/event-bus.service'; 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class OverlayRequestResponseInterceptor implements HttpInterceptor { 11 | 12 | constructor(private eventBus: EventBusService) { } 13 | 14 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 15 | const randomTime = this.getRandomIntInclusive(0, 1500); 16 | const started = Date.now(); 17 | this.eventBus.emit(new EmitEvent(Events.httpRequest)); 18 | return next 19 | .handle(req) 20 | .pipe( 21 | // delay(randomTime), // Simulate random Http call delays 22 | tap(event => { 23 | if (event instanceof HttpResponse) { 24 | const elapsed = Date.now() - started; 25 | this.eventBus.emit(new EmitEvent(Events.httpResponse)); 26 | } 27 | }), 28 | catchError(err => { 29 | this.eventBus.emit(new EmitEvent(Events.httpResponse)); 30 | return of({}) as Observable>; 31 | }) 32 | ); 33 | } 34 | 35 | getRandomIntInclusive(min: number, max: number) { 36 | min = Math.ceil(min); 37 | max = Math.floor(max); 38 | return Math.floor(Math.random() * (max - min + 1)) + min; // The maximum is inclusive and the minimum is inclusive 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/app/core/overlay/overlay.component.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | display:none; 3 | } 4 | 5 | .overlay.active { 6 | display: block; 7 | } 8 | 9 | .overlay-background { 10 | position: fixed; 11 | top: 0; 12 | right: 0; 13 | bottom: 0; 14 | left: 0; 15 | z-index: 1050; 16 | display: block; 17 | overflow: hidden; 18 | -webkit-overflow-scrolling: touch; 19 | outline: 0; 20 | background-color:rgba(0,0,0,0.6); 21 | } 22 | 23 | .overlay-content { 24 | 25 | } 26 | 27 | .overlay-content { 28 | position: fixed; 29 | z-index: 999999; 30 | top: 50%; 31 | left: 50%; 32 | background-color: white; 33 | border: 1px solid rgb(94, 94, 94); 34 | -webkit-transform: translate(-50%, 0%); 35 | transform: translate(-50%, 0%); 36 | 37 | cursor: pointer; 38 | padding: 5; 39 | width: 285px; 40 | height: 100px; 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | 45 | 46 | -webkit-transition: opacity 1s; 47 | -moz-transition: opacity 1s; 48 | -o-transition: opacity 1s; 49 | transition: opacity 1s; 50 | } 51 | -------------------------------------------------------------------------------- /src/app/core/overlay/overlay.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
-------------------------------------------------------------------------------- /src/app/core/overlay/overlay.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, OnDestroy } from '@angular/core'; 2 | 3 | import { EventBusService, Events } from '../services/event-bus.service'; 4 | import { Subscription } from 'rxjs'; 5 | 6 | @Component({ 7 | selector: 'cm-overlay', 8 | templateUrl: './overlay.component.html', 9 | styleUrls: ['./overlay.component.css'], 10 | standalone: true 11 | }) 12 | export class OverlayComponent implements OnInit, OnDestroy { 13 | 14 | httpRequestSub: Subscription = {} as Subscription; 15 | httpResponseSub: Subscription = {} as Subscription; 16 | enabled = false; 17 | queue: any[] = []; 18 | timerId: number = 0; 19 | timerHideId: number = 0; 20 | 21 | @Input() delay = 500; 22 | 23 | constructor(private eventBus: EventBusService) { } 24 | 25 | ngOnInit() { 26 | // Handle request 27 | this.httpRequestSub = this.eventBus.on(Events.httpRequest, (() => { 28 | this.queue.push({}); 29 | if (this.queue.length === 1) { 30 | // Only show if we have an item in the queue after the delay time 31 | setTimeout(() => { 32 | if (this.queue.length) { this.enabled = true; } 33 | }, this.delay); 34 | } 35 | })); 36 | 37 | // Handle response 38 | this.httpResponseSub = this.eventBus.on(Events.httpResponse, (() => { 39 | this.queue.pop(); 40 | if (this.queue.length === 0) { 41 | // Since we don't know if another XHR request will be made, pause before 42 | // hiding the overlay. If another XHR request comes in then the overlay 43 | // will stay visible which prevents a flicker 44 | setTimeout(() => { 45 | // Make sure queue is still 0 since a new XHR request may have come in 46 | // while timer was running 47 | if (this.queue.length === 0) { this.enabled = false; } 48 | }, this.delay); 49 | } 50 | })); 51 | } 52 | 53 | ngOnDestroy() { 54 | this.httpRequestSub.unsubscribe(); 55 | this.httpResponseSub.unsubscribe(); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/app/core/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Output, EventEmitter, Inject, Directive, inject } from '@angular/core'; 2 | import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 3 | 4 | import { Observable, throwError } from 'rxjs'; 5 | import { map, catchError } from 'rxjs/operators'; 6 | 7 | import { IUserLogin } from '../../shared/interfaces'; 8 | import { UtilitiesService } from './utilities.service'; 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class AuthService { 12 | private http = inject(HttpClient); 13 | private utilitiesService = inject(UtilitiesService); 14 | baseUrl = this.utilitiesService.getApiUrl(); 15 | authUrl = this.baseUrl + '/api/auth'; 16 | isAuthenticated = false; 17 | redirectUrl: string = ''; 18 | @Output() authChanged: EventEmitter = new EventEmitter(); 19 | 20 | private userAuthChanged(status: boolean) { 21 | this.authChanged.emit(status); // Raise changed event 22 | } 23 | 24 | login(userLogin: IUserLogin): Observable { 25 | return this.http.post(this.authUrl + '/login', userLogin) 26 | .pipe( 27 | map(loggedIn => { 28 | this.isAuthenticated = loggedIn; 29 | this.userAuthChanged(loggedIn); 30 | return loggedIn; 31 | }), 32 | catchError(this.handleError) 33 | ); 34 | } 35 | 36 | logout(): Observable { 37 | return this.http.post(this.authUrl + '/logout', null) 38 | .pipe( 39 | map(loggedOut => { 40 | this.isAuthenticated = !loggedOut; 41 | this.userAuthChanged(!loggedOut); // Return loggedIn status 42 | return loggedOut; 43 | }), 44 | catchError(this.handleError) 45 | ); 46 | } 47 | 48 | private handleError(error: HttpErrorResponse) { 49 | console.error('server error:', error); 50 | if (error.error instanceof Error) { 51 | const errMessage = error.error.message; 52 | return throwError(() => errMessage); 53 | // return Observable.throw(err.text() || 'backend server error'); 54 | } 55 | return throwError(() => error || 'Server error'); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/app/core/services/data.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 3 | 4 | import { Observable, throwError } from 'rxjs'; 5 | import { map, catchError } from 'rxjs/operators'; 6 | 7 | import { ICustomer, IOrder, IState, IPagedResults, IApiResponse } from '../../shared/interfaces'; 8 | import { UtilitiesService } from './utilities.service'; 9 | 10 | @Injectable({ providedIn: 'root' }) 11 | export class DataService { 12 | private http = inject(HttpClient); 13 | private utilitiesService = inject(UtilitiesService); 14 | baseUrl = this.utilitiesService.getApiUrl(); 15 | customersBaseUrl = this.baseUrl + '/api/customers'; 16 | ordersBaseUrl = this.baseUrl + '/api/orders'; 17 | orders: IOrder[] = []; 18 | states: IState[] = []; 19 | 20 | getCustomersPage(page: number, pageSize: number): Observable> { 21 | return this.http.get( 22 | `${this.customersBaseUrl}/page/${page}/${pageSize}`, 23 | { observe: 'response' }) 24 | .pipe( 25 | map(res => { 26 | const xInlineCount = res.headers.get('X-InlineCount'); 27 | const totalRecords = Number(xInlineCount); 28 | const customers = res.body as ICustomer[]; 29 | this.calculateCustomersOrderTotal(customers); 30 | return { 31 | results: customers, 32 | totalRecords: totalRecords 33 | }; 34 | }), 35 | catchError(this.handleError) 36 | ); 37 | } 38 | 39 | getCustomers(): Observable { 40 | return this.http.get(this.customersBaseUrl) 41 | .pipe( 42 | map(customers => { 43 | this.calculateCustomersOrderTotal(customers); 44 | return customers; 45 | }), 46 | catchError(this.handleError) 47 | ); 48 | } 49 | 50 | getCustomer(id: number): Observable { 51 | return this.http.get(this.customersBaseUrl + '/' + id) 52 | .pipe( 53 | map(customer => { 54 | this.calculateCustomersOrderTotal([customer]); 55 | return customer; 56 | }), 57 | catchError(this.handleError) 58 | ); 59 | } 60 | 61 | insertCustomer(customer: ICustomer): Observable { 62 | return this.http.post(this.customersBaseUrl, customer) 63 | .pipe(catchError(this.handleError)); 64 | } 65 | 66 | updateCustomer(customer: ICustomer): Observable { 67 | return this.http.put(this.customersBaseUrl + '/' + customer.id, customer) 68 | .pipe( 69 | map(res => res.status), 70 | catchError(this.handleError) 71 | ); 72 | } 73 | 74 | deleteCustomer(id: number): Observable { 75 | return this.http.delete(this.customersBaseUrl + '/' + id) 76 | .pipe( 77 | map(res => res.status), 78 | catchError(this.handleError) 79 | ); 80 | } 81 | 82 | getStates(): Observable { 83 | return this.http.get(this.baseUrl + '/api/states') 84 | .pipe(catchError(this.handleError)); 85 | } 86 | 87 | private handleError(error: HttpErrorResponse) { 88 | console.error('server error:', error); 89 | if (error.error instanceof Error) { 90 | const errMessage = error.error.message; 91 | return throwError(() => errMessage); 92 | // Use the following instead if using lite-server 93 | // return Observable.throw(err.text() || 'backend server error'); 94 | } 95 | return throwError(() => error || 'Node.js server error'); 96 | } 97 | 98 | calculateCustomersOrderTotal(customers: ICustomer[]) { 99 | for (const customer of customers) { 100 | if (customer && customer.orders) { 101 | let total = 0; 102 | for (const order of customer.orders) { 103 | total += order.itemCost; 104 | } 105 | customer.orderTotal = total; 106 | } 107 | } 108 | } 109 | 110 | // Not using now but leaving since they show how to create 111 | // and work with custom observables 112 | 113 | // Would need following import added: 114 | // import { Observer } from 'rxjs'; 115 | 116 | // createObservable(data: any): Observable { 117 | // return Observable.create((observer: Observer) => { 118 | // observer.next(data); 119 | // observer.complete(); 120 | // }); 121 | // } 122 | 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/app/core/services/dialog.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class DialogService { 5 | 6 | promise: Promise = {} as Promise; 7 | message = 'Is it OK?'; 8 | 9 | confirm(message?: string) { 10 | if (message) { this.message = message; } 11 | this.promise = new Promise(this.resolver); 12 | return this.promise; 13 | } 14 | 15 | resolver(resolve: any) { 16 | return resolve(window.confirm('Is it OK?')); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/app/core/services/event-bus.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Subject, Subscription, Observable } from 'rxjs'; 3 | import { filter, map } from 'rxjs/operators'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class EventBusService { 7 | 8 | subject = new Subject(); 9 | 10 | constructor() { } 11 | 12 | on(event: Events, action: any): Subscription { 13 | return this.subject 14 | .pipe( 15 | filter((e: EmitEvent) => { 16 | return e.name === event; 17 | }), 18 | map((e: EmitEvent) => { 19 | return e.value; 20 | }) 21 | ) 22 | .subscribe(action); 23 | } 24 | 25 | emit(event: EmitEvent) { 26 | this.subject.next(event); 27 | } 28 | } 29 | 30 | export class EmitEvent { 31 | 32 | constructor(public name: any, public value?: any) { } 33 | 34 | } 35 | 36 | export enum Events { 37 | httpRequest, 38 | httpResponse 39 | } 40 | -------------------------------------------------------------------------------- /src/app/core/services/filter.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PropertyResolver } from '../../core/services/property-resolver'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class FilterService { 7 | 8 | constructor() { } 9 | 10 | filter(items: T[], data: string, props: string[]) { 11 | return items.filter((item: T) => { 12 | let match = false; 13 | for (const prop of props) { 14 | if (prop.indexOf('.') > -1) { 15 | const value = PropertyResolver.resolve(prop, item); 16 | if (value && value.toUpperCase().indexOf(data) > -1) { 17 | match = true; 18 | break; 19 | } 20 | continue; 21 | } 22 | 23 | if ((item as any)[prop].toString().toUpperCase().indexOf(data) > -1) { 24 | match = true; 25 | break; 26 | } 27 | } 28 | return match; 29 | }); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/app/core/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class LoggerService { 5 | 6 | constructor() { } 7 | 8 | log(msg: string) { 9 | console.log(msg); 10 | } 11 | 12 | logError(msg: string) { 13 | console.error(msg); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/app/core/services/property-resolver.ts: -------------------------------------------------------------------------------- 1 | export class PropertyResolver { 2 | static resolve(path: string, obj: any) { 3 | return path.split('.').reduce((prev, curr) => { 4 | return (prev ? prev[curr] : undefined); 5 | }, obj || self); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/core/services/sorter.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { PropertyResolver } from './property-resolver'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class SorterService { 7 | 8 | property: string = ''; 9 | direction = 1; 10 | 11 | sort(collection: any[], prop: any, reverseSort = true) { 12 | this.property = prop; 13 | if (reverseSort) { 14 | this.direction = (this.property === prop) ? this.direction * -1 : 1; 15 | } 16 | 17 | return collection.sort((a: any, b: any) => { 18 | let aVal: any; 19 | let bVal: any; 20 | 21 | // Handle resolving complex properties such as 'state.name' for prop value 22 | if (prop && prop.indexOf('.') > -1) { 23 | aVal = PropertyResolver.resolve(prop, a); 24 | bVal = PropertyResolver.resolve(prop, b); 25 | } else { 26 | aVal = a[prop]; 27 | bVal = b[prop]; 28 | } 29 | 30 | // Fix issues that spaces before/after string value can cause such as ' San Francisco' 31 | if (this.isString(aVal)) { 32 | aVal = aVal.trim().toUpperCase(); 33 | } 34 | 35 | if (this.isString(bVal)) { 36 | bVal = bVal.trim().toUpperCase(); 37 | } 38 | 39 | if (aVal === bVal) { 40 | return 0; 41 | } else if (aVal > bVal) { 42 | return this.direction * -1; 43 | } else { 44 | return this.direction * 1; 45 | } 46 | }); 47 | } 48 | 49 | isString(val: any): boolean { 50 | return (val && (typeof val === 'string' || val instanceof String)); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/app/core/services/trackby.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { ICustomer, IOrder } from '../../shared/interfaces'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class TrackByService { 7 | 8 | customer(index: number, customer: ICustomer) { 9 | return customer.id; 10 | } 11 | 12 | order(index: number, order: IOrder) { 13 | return index; 14 | } 15 | 16 | 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/app/core/services/utilities.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class UtilitiesService { 5 | constructor(@Inject('Window') private window: Window) { } 6 | 7 | getApiUrl() { 8 | const port = this.getPort(); 9 | if (import.meta.env.NG_APP_API_URL) { 10 | return import.meta.env.NG_APP_API_URL; 11 | } 12 | return `${this.window.location.protocol}//${this.window.location.hostname}${port}`; 13 | } 14 | 15 | private getPort() { 16 | const port = this.window.location.port; 17 | if (port) { 18 | // for running with Azure Functions local emulator 19 | if (port === '4200') { 20 | // Local run with 'npm run' also started in api folder for Azure Functions 21 | return ':7071'; // for debugging Azure Functions locally 22 | } 23 | // Running with local node (which serves Angular and the API) 24 | return ':' + this.window.location.port; 25 | } 26 | else { 27 | // for running locally with Docker/Kubernetes 28 | if (this.window.location.hostname === 'localhost') { 29 | return ':8080'; 30 | } 31 | } 32 | return ''; 33 | } 34 | } -------------------------------------------------------------------------------- /src/app/core/services/validation.service.ts: -------------------------------------------------------------------------------- 1 | // Original version created by Cory Rylan: https://coryrylan.com/blog/angular-2-form-builder-and-validation-management 2 | import { AbstractControl } from '@angular/forms'; 3 | 4 | export class ValidationService { 5 | 6 | static getValidatorErrorMessage(code: string) { 7 | const config: any = { 8 | 'required': 'Required', 9 | 'invalidCreditCard': 'Is invalid credit card number', 10 | 'invalidEmailAddress': 'Invalid email address', 11 | 'invalidPassword': 'Invalid password. Password must be at least 6 characters long, and contain a number.' 12 | }; 13 | return config[code]; 14 | } 15 | 16 | static creditCardValidator(control: AbstractControl) { 17 | // Visa, MasterCard, American Express, Diners Club, Discover, JCB 18 | // tslint:disable-next-line 19 | if (control.value.match(/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$/)) { 20 | return null; 21 | } else { 22 | return { 'invalidCreditCard': true }; 23 | } 24 | } 25 | 26 | static emailValidator(control: AbstractControl) { 27 | // RFC 2822 compliant regex 28 | // tslint:disable-next-line 29 | if (control.value.match(/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/)) { 30 | return null; 31 | } else { 32 | return { 'invalidEmailAddress': true }; 33 | } 34 | } 35 | 36 | static passwordValidator(control: AbstractControl) { 37 | // {6,100} - Assert password is between 6 and 100 characters 38 | // (?=.*[0-9]) - Assert a string has at least one number 39 | // (?!.*\s) - Spaces are not allowed 40 | // tslint:disable-next-line 41 | if (control.value.match(/^(?=.*\d)(?=.*[a-zA-Z!@#$%^&*])(?!.*\s).{6,100}$/)) { 42 | return null; 43 | } else { 44 | return { 'invalidPassword': true }; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/core/strategies/preload-modules.strategy.ts: -------------------------------------------------------------------------------- 1 | // Preloading example from https://angular.io/docs/ts/latest/guide/router.html#!#custom-preloading 2 | 3 | import { Injectable } from '@angular/core'; 4 | import { PreloadingStrategy, Route } from '@angular/router'; 5 | import { Observable, of } from 'rxjs'; 6 | import { LoggerService } from '../services/logger.service'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class PreloadModulesStrategy implements PreloadingStrategy { 10 | 11 | constructor(private logger: LoggerService) {} 12 | 13 | preload(route: Route, load: () => Observable): Observable { 14 | if (route.data && route.data['preload']) { 15 | this.logger.log('Preloaded: ' + route.path); 16 | return load(); 17 | } else { 18 | return of(null); 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/app/customer/customer-details/customer-details.component.css: -------------------------------------------------------------------------------- 1 | .details-image { 2 | height:100px;width:100px;margin-top:10px; 3 | } -------------------------------------------------------------------------------- /src/app/customer/customer-details/customer-details.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |
7 |

8 | {{ customer.firstName | capitalize }} {{ customer.lastName | capitalize }}  9 |

10 |
11 | {{ customer.address }} 12 |
13 | {{ customer.city }}, {{ customer.state.name }} 14 |
15 |
16 |

17 |
18 |
19 | 24 | 25 | 26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 | No customer found 34 |
-------------------------------------------------------------------------------- /src/app/customer/customer-details/customer-details.component.sandbox.ts: -------------------------------------------------------------------------------- 1 | import { sandboxOf } from 'angular-playground'; 2 | import { DataService } from '../../core/services/data.service'; 3 | import { CustomerDetailsComponent } from './customer-details.component'; 4 | import { MockDataService, MockActivatedRoute, getActivatedRouteWithParent } from '../../shared/mocks'; 5 | import { ActivatedRoute } from '@angular/router'; 6 | 7 | const sandboxConfig = { 8 | providers: [ 9 | { provide: DataService, useClass: MockDataService }, 10 | { provide: ActivatedRoute, useFactory: () => { 11 | const route = getActivatedRouteWithParent([{ id: '1' }]); 12 | return route; 13 | }} 14 | ], 15 | label: 'Customer Details Component' 16 | }; 17 | 18 | export default sandboxOf(CustomerDetailsComponent, sandboxConfig) 19 | .add('With a Customer', { 20 | template: `` 21 | }) 22 | .add('Without a Customer', { 23 | template: ``, 24 | providers: [ 25 | { provide: ActivatedRoute, useFactory: () => { 26 | const route = getActivatedRouteWithParent([{ id: null }]); 27 | return route; 28 | }} 29 | ] 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/customer/customer-details/customer-details.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ComponentRef, ViewChild, ViewContainerRef, ComponentFactoryResolver } from '@angular/core'; 2 | import { ActivatedRoute, Params } from '@angular/router'; 3 | 4 | import { ICustomer } from '../../shared/interfaces'; 5 | import { DataService } from '../../core/services/data.service'; 6 | import { MapPointComponent } from '../../shared/map/map-point.component'; 7 | import { CapitalizePipe } from '../../shared/pipes/capitalize.pipe'; 8 | import { NgIf, LowerCasePipe } from '@angular/common'; 9 | 10 | @Component({ 11 | selector: 'cm-customer-details', 12 | templateUrl: './customer-details.component.html', 13 | styleUrls: ['./customer-details.component.css'], 14 | standalone: true, 15 | imports: [NgIf, LowerCasePipe, CapitalizePipe] 16 | }) 17 | export class CustomerDetailsComponent implements OnInit { 18 | 19 | customer: ICustomer | null = null; 20 | mapEnabled: boolean = false; 21 | mapComponentRef: ComponentRef = {} as ComponentRef; 22 | 23 | @ViewChild('mapsContainer', { read: ViewContainerRef }) 24 | private mapsViewContainerRef: ViewContainerRef = {} as ViewContainerRef; 25 | 26 | constructor(private route: ActivatedRoute, 27 | private dataService: DataService, 28 | private componentFactoryResolver: ComponentFactoryResolver) { } 29 | 30 | ngOnInit() { 31 | // Subscribe to params so if it changes we pick it up. Could use this.route.parent.snapshot.params["id"] to simplify it. 32 | this.route.parent?.params.subscribe((params: Params) => { 33 | const id = +params['id']; 34 | if (id) { 35 | this.dataService.getCustomer(id) 36 | .subscribe((customer: ICustomer) => { 37 | this.customer = customer; 38 | if (this.customer && this.customer.latitude) { 39 | this.lazyLoadMapComponent(); 40 | // this.mapEnabled = true; // For eager loading map 41 | } 42 | }); 43 | } 44 | }); 45 | } 46 | 47 | async lazyLoadMapComponent() { 48 | if (!this.mapsViewContainerRef.length) { 49 | // Lazy load MapComponent 50 | const { MapComponent } = await import('../../shared/map/map.component'); 51 | console.log('Lazy loaded map component for customer!'); 52 | this.mapComponentRef = this.mapsViewContainerRef.createComponent(MapComponent); 53 | this.mapComponentRef.instance.zoom = 10; 54 | this.mapComponentRef.instance.customer = this.customer; 55 | this.mapComponentRef.instance.enabled = true; 56 | } 57 | } 58 | 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/app/customer/customer-edit/customer-edit.component.css: -------------------------------------------------------------------------------- 1 | .customer-form input[type='text'], 2 | .customer-form input[type='number'], 3 | .customer-form input[type='email'], 4 | .customer-form select { 5 | width:100%; 6 | } 7 | 8 | .customer-form .ng-invalid { 9 | border-left: 5px solid #a94442; 10 | } 11 | 12 | .customer-form .ng-valid { 13 | border-left: 5px solid #42A948; 14 | } -------------------------------------------------------------------------------- /src/app/customer/customer-edit/customer-edit.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
First Name is required
7 |
8 |
9 | 10 | 11 |
Last Name is required
12 |
13 |
14 | 15 | 16 |
Address is required
17 |
18 |
19 | 20 | 21 |
City is required
22 |
23 |
24 | 25 | 31 |
32 |
33 |
34 | Delete Customer?     35 | 36 |
37 |    38 | 39 |
40 |    41 | 42 |
43 |
44 |
{{ errorMessage }}
45 |
46 |
47 |
-------------------------------------------------------------------------------- /src/app/customer/customer-edit/customer-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { Router, ActivatedRoute, Params } from '@angular/router'; 3 | import { NgForm, FormsModule } from '@angular/forms'; 4 | 5 | import { DataService } from '../../core/services/data.service'; 6 | import { ModalService, IModalContent } from '../../core/modal/modal.service'; 7 | import { ICustomer, IState } from '../../shared/interfaces'; 8 | import { GrowlerService, GrowlerMessageType } from '../../core/growler/growler.service'; 9 | import { LoggerService } from '../../core/services/logger.service'; 10 | import { NgFor, NgIf } from '@angular/common'; 11 | 12 | @Component({ 13 | selector: 'cm-customer-edit', 14 | templateUrl: './customer-edit.component.html', 15 | styleUrls: ['./customer-edit.component.css'], 16 | standalone: true, 17 | imports: [FormsModule, NgFor, NgIf] 18 | }) 19 | export class CustomerEditComponent implements OnInit { 20 | 21 | customer: ICustomer = 22 | { 23 | id: 0, 24 | firstName: '', 25 | lastName: '', 26 | gender: '', 27 | address: '', 28 | city: '', 29 | state: { 30 | abbreviation: '', 31 | name: '' 32 | } 33 | }; 34 | states: IState[] = []; 35 | errorMessage: string = ''; 36 | deleteMessageEnabled: boolean = false; 37 | operationText = 'Insert'; 38 | @ViewChild('customerForm', { static: true }) customerForm: NgForm = {} as NgForm; 39 | 40 | constructor(private router: Router, 41 | private route: ActivatedRoute, 42 | private dataService: DataService, 43 | private growler: GrowlerService, 44 | private modalService: ModalService, 45 | private logger: LoggerService) { } 46 | 47 | ngOnInit() { 48 | // Subscribe to params so if it changes we pick it up. Don't technically need that here 49 | // since param won't be changing while component is alive. 50 | // Could use this.route.parent.snapshot.params["id"] to simplify it. 51 | this.route.parent?.params.subscribe((params: Params) => { 52 | const id = +params['id']; 53 | if (id !== 0) { 54 | this.operationText = 'Update'; 55 | this.getCustomer(id); 56 | } 57 | }); 58 | 59 | this.dataService.getStates().subscribe((states: IState[]) => this.states = states); 60 | } 61 | 62 | getCustomer(id: number) { 63 | this.dataService.getCustomer(id).subscribe((customer: ICustomer) => { 64 | this.customer = customer; 65 | }); 66 | } 67 | 68 | submit() { 69 | if (this.customer.id === 0) { 70 | this.dataService.insertCustomer(this.customer) 71 | .subscribe((insertedCustomer: ICustomer) => { 72 | if (insertedCustomer) { 73 | // Mark form as pristine so that CanDeactivateGuard won't prompt before navigation 74 | this.customerForm.form.markAsPristine(); 75 | this.router.navigate(['/customers']); 76 | } else { 77 | const msg = 'Unable to insert customer'; 78 | this.growler.growl(msg, GrowlerMessageType.Danger); 79 | this.errorMessage = msg; 80 | } 81 | }, 82 | (err: any) => this.logger.log(err)); 83 | } else { 84 | this.dataService.updateCustomer(this.customer) 85 | .subscribe((status: boolean) => { 86 | if (status) { 87 | // Mark form as pristine so that CanDeactivateGuard won't prompt before navigation 88 | this.customerForm.form.markAsPristine(); 89 | this.growler.growl('Operation performed successfully.', GrowlerMessageType.Success); 90 | // this.router.navigate(['/customers']); 91 | } else { 92 | const msg = 'Unable to update customer'; 93 | this.growler.growl(msg, GrowlerMessageType.Danger); 94 | this.errorMessage = msg; 95 | } 96 | }, 97 | (err: any) => this.logger.log(err)); 98 | } 99 | } 100 | 101 | cancel(event: Event) { 102 | event.preventDefault(); 103 | // Route guard will take care of showing modal dialog service if data is dirty 104 | this.router.navigate(['/customers']); 105 | } 106 | 107 | delete(event: Event) { 108 | event.preventDefault(); 109 | this.dataService.deleteCustomer(this.customer.id) 110 | .subscribe((status: boolean) => { 111 | if (status) { 112 | this.router.navigate(['/customers']); 113 | } else { 114 | this.errorMessage = 'Unable to delete customer'; 115 | } 116 | }, 117 | (err) => this.logger.log(err)); 118 | } 119 | 120 | canDeactivate(): Promise | boolean { 121 | if (!this.customerForm.dirty) { 122 | return true; 123 | } 124 | 125 | // Dirty show display modal dialog to user to confirm leaving 126 | const modalContent: IModalContent = { 127 | header: 'Lose Unsaved Changes?', 128 | body: 'You have unsaved changes! Would you like to leave the page and lose them?', 129 | cancelButtonText: 'Cancel', 130 | OKButtonText: 'Leave' 131 | }; 132 | return this.modalService.show(modalContent); 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /src/app/customer/customer-orders/customer-orders.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Orders for {{ customer.firstName | capitalize }} {{ customer.lastName | capitalize }}

4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
{{ order.productName }}{{ order.itemCost | currency:'USD':'symbol' }}
 {{ customer.orderTotal | currency:'USD':'symbol' }}
15 |
16 |
17 | No orders found 18 |
19 |
20 | No customer found 21 |
22 |
-------------------------------------------------------------------------------- /src/app/customer/customer-orders/customer-orders.component.sandbox.ts: -------------------------------------------------------------------------------- 1 | import { sandboxOf } from 'angular-playground'; 2 | import { DataService } from '../../core/services/data.service'; 3 | import { CustomerOrdersComponent } from './customer-orders.component'; 4 | import { MockDataService, MockActivatedRoute, getActivatedRouteWithParent } from '../../shared/mocks'; 5 | import { ActivatedRoute } from '@angular/router'; 6 | 7 | const sandboxConfig = { 8 | providers: [ 9 | { provide: DataService, useClass: MockDataService }, 10 | { provide: ActivatedRoute, useFactory: () => { 11 | const route = getActivatedRouteWithParent([{ id: '1' }]); 12 | return route; 13 | }} 14 | ], 15 | label: 'Customer Orders Component' 16 | }; 17 | 18 | export default sandboxOf(CustomerOrdersComponent, sandboxConfig) 19 | .add('With Orders', { 20 | template: `` 21 | }) 22 | .add('Without Orders', { 23 | template: ``, 24 | providers: [ { provide: ActivatedRoute, useFactory: () => { 25 | const route = getActivatedRouteWithParent([{ id: null }]); 26 | return route; 27 | }}] 28 | }); 29 | -------------------------------------------------------------------------------- /src/app/customer/customer-orders/customer-orders.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Params } from '@angular/router'; 3 | 4 | import { DataService } from '../../core/services/data.service'; 5 | import { ICustomer, IOrder, IOrderItem } from '../../shared/interfaces'; 6 | import { CapitalizePipe } from '../../shared/pipes/capitalize.pipe'; 7 | import { NgIf, NgFor, CurrencyPipe } from '@angular/common'; 8 | 9 | @Component({ 10 | selector: 'cm-customer-orders', 11 | templateUrl: './customer-orders.component.html', 12 | standalone: true, 13 | imports: [NgIf, NgFor, CurrencyPipe, CapitalizePipe] 14 | }) 15 | export class CustomerOrdersComponent implements OnInit { 16 | 17 | orders: IOrder[] = []; 18 | customer: ICustomer = {} as ICustomer; 19 | 20 | constructor(private route: ActivatedRoute, private dataService: DataService) { } 21 | 22 | ngOnInit() { 23 | // Subscribe to params so if it changes we pick it up. Could use this.route.parent.snapshot.params["id"] to simplify it. 24 | this.route.parent?.params.subscribe((params: Params) => { 25 | const id = +params['id']; 26 | this.dataService.getCustomer(id).subscribe((customer: ICustomer) => { 27 | this.customer = customer; 28 | }); 29 | }); 30 | } 31 | 32 | ordersTrackBy(index: number, orderItem: any) { 33 | return index; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/app/customer/customer.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

  Customer Information

5 |
6 |
7 | 26 |
27 | 28 |
29 |
30 | View all Customers 31 |
32 |
33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/app/customer/customer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'cm-orders', 6 | templateUrl: './customer.component.html', 7 | standalone: true, 8 | imports: [RouterLink, RouterLinkActive, RouterOutlet] 9 | }) 10 | export class CustomerComponent implements OnInit { 11 | 12 | // displayMode: CustomerDisplayModeEnum; 13 | // displayModeEnum = CustomerDisplayModeEnum; 14 | 15 | constructor(private router: Router) { } 16 | 17 | ngOnInit() { 18 | 19 | // No longer needed due to routerLinkActive feature in Angular 20 | // const path = this.router.url.split('/')[3]; 21 | // switch (path) { 22 | // case 'details': 23 | // this.displayMode = CustomerDisplayModeEnum.Details; 24 | // break; 25 | // case 'orders': 26 | // this.displayMode = CustomerDisplayModeEnum.Orders; 27 | // break; 28 | // case 'edit': 29 | // this.displayMode = CustomerDisplayModeEnum.Edit; 30 | // break; 31 | // } 32 | } 33 | 34 | } 35 | 36 | // enum CustomerDisplayModeEnum { 37 | // Details=0, 38 | // Orders=1, 39 | // Edit=2 40 | // } 41 | -------------------------------------------------------------------------------- /src/app/customer/guards/can-activate.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { AuthService } from '../../core/services/auth.service'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class CanActivateGuard { 9 | 10 | constructor(private authService: AuthService, private router: Router) { } 11 | 12 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean { 13 | if (this.authService.isAuthenticated) { 14 | return true; 15 | } 16 | 17 | // Track URL user is trying to go to so we can send them there after logging in 18 | this.authService.redirectUrl = state.url; 19 | this.router.navigate(['/login']); 20 | return false; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/app/customer/guards/can-deactivate.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { CustomerEditComponent } from '../customer-edit/customer-edit.component'; 6 | import { LoggerService } from '../../core/services/logger.service'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class CanDeactivateGuard { 10 | 11 | constructor(private logger: LoggerService) {} 12 | 13 | canDeactivate( 14 | component: CustomerEditComponent, 15 | route: ActivatedRouteSnapshot, 16 | state: RouterStateSnapshot 17 | ): Observable | Promise | boolean { 18 | 19 | this.logger.log(`CustomerId: ${route.parent?.params['id']} URL: ${state.url}`); 20 | 21 | // Check with component to see if we're able to deactivate 22 | return component.canDeactivate(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/customer/routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from "@angular/router"; 2 | import { CustomerComponent } from "./customer.component"; 3 | import { CustomerOrdersComponent } from "./customer-orders/customer-orders.component"; 4 | import { CustomerDetailsComponent } from "./customer-details/customer-details.component"; 5 | import { CustomerEditComponent } from "./customer-edit/customer-edit.component"; 6 | import { CanActivateGuard } from "./guards/can-activate.guard"; 7 | import { CanDeactivateGuard } from "./guards/can-deactivate.guard"; 8 | 9 | export const CUSTOMER_ROUTES: Routes = [ 10 | { 11 | path: '', 12 | component: CustomerComponent, 13 | children: [ 14 | { path: 'orders', component: CustomerOrdersComponent }, 15 | { path: 'details', component: CustomerDetailsComponent }, 16 | { 17 | path: 'edit', 18 | component: CustomerEditComponent, 19 | canActivate: [CanActivateGuard], 20 | canDeactivate: [CanDeactivateGuard] 21 | } 22 | ] 23 | } 24 | ]; -------------------------------------------------------------------------------- /src/app/customers/customers-card/customers-card.component.css: -------------------------------------------------------------------------------- 1 | .card-container { 2 | width:85%; 3 | } 4 | 5 | .card { 6 | background-color:#fff; 7 | border: 1px solid #d4d4d4; 8 | height:120px; 9 | margin-bottom: 20px; 10 | position: relative; 11 | } 12 | 13 | .card-header { 14 | background-color:#027FF4; 15 | font-size:14pt; 16 | color:white; 17 | padding:5px; 18 | width:100%; 19 | } 20 | 21 | .card-close { 22 | color: white; 23 | font-weight:bold; 24 | margin-right:5px; 25 | } 26 | 27 | .card-body { 28 | padding-left: 5px; 29 | } 30 | 31 | .card-body-left { 32 | margin-top: -5px; 33 | } 34 | 35 | .card-body-right { 36 | margin-left: 20px; 37 | margin-top: 2px; 38 | } 39 | 40 | .card-body-content { 41 | width: 100px; 42 | } 43 | 44 | .card-image { 45 | height:50px;width:50px;margin-top:10px; 46 | } 47 | 48 | .white { 49 | color: white; 50 | } 51 | 52 | .white:hover { 53 | color: white; 54 | } -------------------------------------------------------------------------------- /src/app/customers/customers-card/customers-card.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 14 |
15 |
16 |
17 | 18 | 19 | 20 |
21 |
22 |
{{customer.city | trim }}, {{customer.state.name}}
23 | View Orders 24 |
25 |
26 |
27 |
28 |
29 |
30 | No Records Found 31 |
32 |
33 |
-------------------------------------------------------------------------------- /src/app/customers/customers-card/customers-card.component.sandbox.ts: -------------------------------------------------------------------------------- 1 | import { sandboxOf } from 'angular-playground'; 2 | import { CustomersCardComponent } from './customers-card.component'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { customers } from '../../shared/mocks'; 5 | 6 | const sandboxConfig = { 7 | imports: [ RouterTestingModule ], 8 | label: 'Customers Card Component' 9 | }; 10 | 11 | export default sandboxOf(CustomersCardComponent, sandboxConfig) 12 | .add('With Many Customers', { 13 | template: ``, 14 | context: { 15 | customers: customers 16 | } 17 | }) 18 | .add('With 10 Customers', { 19 | template: ``, 20 | context: { 21 | customers: customers.slice(0, 10) 22 | } 23 | }) 24 | .add('With 4 Customers', { 25 | template: ``, 26 | context: { 27 | customers: customers.slice(0, 4) 28 | } 29 | }) 30 | .add('Without Customers', { 31 | template: `` 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /src/app/customers/customers-card/customers-card.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; 2 | 3 | import { ICustomer } from '../../shared/interfaces'; 4 | import { TrackByService } from '../../core/services/trackby.service'; 5 | import { TrimPipe } from '../../shared/pipes/trim.pipe'; 6 | import { CapitalizePipe } from '../../shared/pipes/capitalize.pipe'; 7 | import { RouterLink } from '@angular/router'; 8 | import { NgFor, NgIf, LowerCasePipe } from '@angular/common'; 9 | 10 | @Component({ 11 | selector: 'cm-customers-card', 12 | templateUrl: './customers-card.component.html', 13 | styleUrls: ['./customers-card.component.css'], 14 | // When using OnPush detectors, then the framework will check an OnPush 15 | // component when any of its input properties changes, when it fires 16 | // an event, or when an observable fires an event ~ Victor Savkin (Angular Team) 17 | changeDetection: ChangeDetectionStrategy.OnPush, 18 | standalone: true, 19 | imports: [NgFor, RouterLink, NgIf, LowerCasePipe, CapitalizePipe, TrimPipe] 20 | }) 21 | export class CustomersCardComponent implements OnInit { 22 | 23 | @Input() customers: ICustomer[] = []; 24 | 25 | constructor(public trackbyService: TrackByService) { } 26 | 27 | ngOnInit() { 28 | 29 | } 30 | 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/app/customers/customers-grid/customers-grid.component.css: -------------------------------------------------------------------------------- 1 | .grid-container div { 2 | padding-left: 0px; 3 | } 4 | 5 | .grid-container td { 6 | vertical-align: middle; 7 | } 8 | 9 | .grid-image { 10 | height:50px;width:50px;margin-top:10px; 11 | } -------------------------------------------------------------------------------- /src/app/customers/customers-grid/customers-grid.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
 First NameLast NameAddressCityStateOrder Total 
Customer Image{{ customer.firstName | capitalize }}{{ customer.lastName | capitalize }}{{ customer.address }}{{ customer.city | trim }}{{ customer.state.name }}{{ customer.orderTotal | currency:'USD':'symbol' }}View Orders
 No Records Found
37 |
38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /src/app/customers/customers-grid/customers-grid.component.sandbox.ts: -------------------------------------------------------------------------------- 1 | import { sandboxOf } from 'angular-playground'; 2 | import { CustomersGridComponent } from './customers-grid.component'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { customers } from '../../shared/mocks'; 5 | 6 | const sandboxConfig = { 7 | imports: [ RouterTestingModule ], 8 | label: 'Customers Grid Component' 9 | }; 10 | 11 | export default sandboxOf(CustomersGridComponent, sandboxConfig) 12 | .add('With Many Customers', { 13 | template: ``, 14 | context: { 15 | customers: customers 16 | } 17 | }) 18 | .add('With 10 Customers', { 19 | template: ``, 20 | context: { 21 | customers: customers.slice(0, 10) 22 | } 23 | }) 24 | .add('With 4 Customers', { 25 | template: ``, 26 | context: { 27 | customers: customers.slice(0, 4) 28 | } 29 | }) 30 | .add('Without Customers', { 31 | template: `` 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /src/app/customers/customers-grid/customers-grid.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; 2 | 3 | import { SorterService } from '../../core/services/sorter.service'; 4 | import { TrackByService } from '../../core/services/trackby.service'; 5 | import { ICustomer } from '../../shared/interfaces'; 6 | import { TrimPipe } from '../../shared/pipes/trim.pipe'; 7 | import { CapitalizePipe } from '../../shared/pipes/capitalize.pipe'; 8 | import { RouterLink } from '@angular/router'; 9 | import { NgFor, NgIf, LowerCasePipe, CurrencyPipe } from '@angular/common'; 10 | import { SortByDirective } from '../../shared/directives/sortby.directive'; 11 | 12 | @Component({ 13 | selector: 'cm-customers-grid', 14 | templateUrl: './customers-grid.component.html', 15 | styleUrls: ['./customers-grid.component.css'], 16 | // When using OnPush detectors, then the framework will check an OnPush 17 | // component when any of its input properties changes, when it fires 18 | // an event, or when an observable fires an event ~ Victor Savkin (Angular Team) 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | standalone: true, 21 | imports: [SortByDirective, NgFor, RouterLink, NgIf, LowerCasePipe, CurrencyPipe, CapitalizePipe, TrimPipe] 22 | }) 23 | export class CustomersGridComponent implements OnInit { 24 | 25 | @Input() customers: ICustomer[] = []; 26 | 27 | constructor(private sorterService: SorterService, public trackbyService: TrackByService) { } 28 | 29 | ngOnInit() { 30 | 31 | } 32 | 33 | sort(prop: string) { 34 | this.customers = this.sorterService.sort(this.customers, prop); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/app/customers/customers.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | 6 | {{ title }} 7 |

8 |
9 |
10 |
11 |
12 | 38 |
39 |
40 | 41 | 44 | 45 | 48 | 49 | 50 |
51 | 52 |
53 | 54 | 59 | 69 | 70 | 74 | 75 |
76 |
77 | -------------------------------------------------------------------------------- /src/app/customers/customers.component.sandbox.ts: -------------------------------------------------------------------------------- 1 | import { RouterTestingModule } from '@angular/router/testing'; 2 | import { sandboxOf } from 'angular-playground'; 3 | 4 | import { CustomersComponent } from './customers.component'; 5 | import { CustomersCardComponent } from './customers-card/customers-card.component'; 6 | import { CustomersGridComponent } from './customers-grid/customers-grid.component'; 7 | import { customers, MockDataService } from '../shared/mocks'; 8 | import { DataService } from '../core/services/data.service'; 9 | 10 | const sandboxConfig = { 11 | imports: [ RouterTestingModule ], 12 | declarations: [ CustomersCardComponent, CustomersGridComponent ], 13 | providers: [ 14 | { provide: DataService, useClass: MockDataService } 15 | ], 16 | label: 'Customers Component' 17 | }; 18 | 19 | export default sandboxOf(CustomersComponent, sandboxConfig) 20 | .add('With Customers', { 21 | template: `` 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/customers/customers.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, 2 | ViewContainerRef, ComponentFactoryResolver, ComponentRef } from '@angular/core'; 3 | 4 | import { DataService } from '../core/services/data.service'; 5 | import { ICustomer, IPagedResults } from '../shared/interfaces'; 6 | import { FilterService } from '../core/services/filter.service'; 7 | import { LoggerService } from '../core/services/logger.service'; 8 | import { PaginationComponent } from '../shared/pagination/pagination.component'; 9 | import { CustomersGridComponent } from './customers-grid/customers-grid.component'; 10 | import { CustomersCardComponent } from './customers-card/customers-card.component'; 11 | import { FilterTextboxComponent } from '../shared/filter-textbox/filter-textbox.component'; 12 | import { RouterLink } from '@angular/router'; 13 | 14 | 15 | @Component({ 16 | selector: 'cm-customers', 17 | templateUrl: './customers.component.html', 18 | standalone: true, 19 | imports: [RouterLink, FilterTextboxComponent, CustomersCardComponent, CustomersGridComponent, PaginationComponent] 20 | }) 21 | export class CustomersComponent implements OnInit { 22 | 23 | title: string = ''; 24 | filterText: string = ''; 25 | customers: ICustomer[] = []; 26 | displayMode: DisplayModeEnum = DisplayModeEnum.Card; 27 | displayModeEnum = DisplayModeEnum; 28 | totalRecords = 0; 29 | pageSize = 10; 30 | mapComponentRef: ComponentRef = {} as ComponentRef; 31 | _filteredCustomers: ICustomer[] = []; 32 | 33 | get filteredCustomers() { 34 | return this._filteredCustomers; 35 | } 36 | 37 | set filteredCustomers(value: ICustomer[]) { 38 | this._filteredCustomers = value; 39 | this.updateMapComponentDataPoints(); 40 | } 41 | 42 | @ViewChild('mapsContainer', { read: ViewContainerRef }) 43 | private mapsViewContainerRef: ViewContainerRef = {} as ViewContainerRef; 44 | 45 | constructor(private componentFactoryResolver: ComponentFactoryResolver, 46 | private dataService: DataService, 47 | private filterService: FilterService, 48 | private logger: LoggerService) { } 49 | 50 | ngOnInit() { 51 | this.title = 'Customers'; 52 | this.filterText = 'Filter Customers:'; 53 | this.displayMode = DisplayModeEnum.Card; 54 | 55 | this.getCustomersPage(1); 56 | } 57 | 58 | changeDisplayMode(mode: DisplayModeEnum) { 59 | this.displayMode = mode; 60 | } 61 | 62 | pageChanged(page: number) { 63 | this.getCustomersPage(page); 64 | } 65 | 66 | getCustomersPage(page: number) { 67 | this.dataService.getCustomersPage((page - 1) * this.pageSize, this.pageSize) 68 | .subscribe((response: IPagedResults) => { 69 | this.customers = this.filteredCustomers = response.results; 70 | this.totalRecords = response.totalRecords; 71 | }, 72 | (err: any) => this.logger.log(err), 73 | () => this.logger.log('getCustomersPage() retrieved customers for page: ' + page)); 74 | } 75 | 76 | filterChanged(data: string) { 77 | if (data && this.customers) { 78 | data = data.toUpperCase(); 79 | const props = ['firstName', 'lastName', 'city', 'state.name']; 80 | this.filteredCustomers = this.filterService.filter(this.customers, data, props); 81 | } else { 82 | this.filteredCustomers = this.customers; 83 | } 84 | } 85 | 86 | async lazyLoadMapComponent() { 87 | this.changeDisplayMode(DisplayModeEnum.Map); 88 | if (!this.mapsViewContainerRef.length) { 89 | // Lazy load MapComponent 90 | const { MapComponent } = await import('../shared/map/map.component'); 91 | console.log('Lazy loaded map component!'); 92 | this.mapComponentRef = this.mapsViewContainerRef.createComponent(MapComponent); 93 | this.mapComponentRef.instance.zoom = 2; 94 | this.mapComponentRef.instance.dataPoints = this.filteredCustomers; 95 | this.mapComponentRef.instance.enabled = true; 96 | } 97 | } 98 | 99 | updateMapComponentDataPoints() { 100 | if (this.mapComponentRef && this.mapComponentRef.instance) { 101 | this.mapComponentRef.instance.dataPoints = this.filteredCustomers; 102 | } 103 | } 104 | 105 | 106 | } 107 | 108 | enum DisplayModeEnum { 109 | Card = 0, 110 | Grid = 1, 111 | Map = 2 112 | } 113 | -------------------------------------------------------------------------------- /src/app/login/login.component.css: -------------------------------------------------------------------------------- 1 | .login-form input[type='text'], 2 | .login-form input[type='email'], 3 | .login-form input[type='password'] { 4 | width:75%; 5 | } 6 | 7 | .login-form .ng-invalid { 8 | border-left: 5px solid #a94442; 9 | } 10 | 11 | .login-form .ng-valid { 12 | border-left: 5px solid #42A948; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Login

5 |
6 |
7 | 47 | 48 |
49 |
-------------------------------------------------------------------------------- /src/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { AbstractControl, FormBuilder, FormGroup, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { AuthService } from '../core/services/auth.service'; 6 | import { ValidationService } from '../core/services/validation.service'; 7 | import { IUserLogin } from '../shared/interfaces'; 8 | import { GrowlerService, GrowlerMessageType } from '../core/growler/growler.service'; 9 | import { LoggerService } from '../core/services/logger.service'; 10 | import { NgIf } from '@angular/common'; 11 | 12 | @Component({ 13 | selector: 'cm-login', 14 | templateUrl: './login.component.html', 15 | styleUrls: ['./login.component.css'], 16 | standalone: true, 17 | imports: [FormsModule, ReactiveFormsModule, NgIf] 18 | }) 19 | export class LoginComponent implements OnInit { 20 | loginForm: FormGroup = {} as FormGroup; 21 | errorMessage: string = ''; 22 | get f(): { [key: string]: AbstractControl } { 23 | return this.loginForm.controls; 24 | } 25 | 26 | constructor(private formBuilder: FormBuilder, 27 | private router: Router, 28 | private authService: AuthService, 29 | private growler: GrowlerService, 30 | private logger: LoggerService) { } 31 | 32 | ngOnInit() { 33 | this.buildForm(); 34 | } 35 | 36 | buildForm() { 37 | this.loginForm = this.formBuilder.group({ 38 | email: ['', [ Validators.required, ValidationService.emailValidator ]], 39 | password: ['', [ Validators.required, ValidationService.passwordValidator ]] 40 | }); 41 | } 42 | 43 | submit({ value, valid }: { value: IUserLogin, valid: boolean }) { 44 | this.authService.login(value) 45 | .subscribe((status: boolean) => { 46 | if (status) { 47 | this.growler.growl('Logged in', GrowlerMessageType.Info); 48 | if (this.authService.redirectUrl) { 49 | const redirectUrl = this.authService.redirectUrl; 50 | this.authService.redirectUrl = ''; 51 | this.router.navigate([redirectUrl]); 52 | } else { 53 | this.router.navigate(['/customers']); 54 | } 55 | } else { 56 | const loginError = 'Unable to login'; 57 | this.errorMessage = loginError; 58 | this.growler.growl(loginError, GrowlerMessageType.Danger); 59 | } 60 | }, 61 | (err: any) => this.logger.log(err)); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/app/orders/orders.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 |   Orders 6 |

7 |
8 |
9 | 10 |
11 |
12 | 13 |
14 |

{{ customer.firstName | capitalize }} {{ customer.lastName | capitalize }}

15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 |
{{order.productName}}{{ order.itemCost | currency:'USD':'symbol' }}
 {{ customer.orderTotal | currency:'USD':'symbol' }} 24 |
26 |
27 | No orders found 28 |
29 |
30 | 31 | 35 | 36 |
37 |
38 | No customers found 39 |
40 |
41 | 42 |
43 |
44 | -------------------------------------------------------------------------------- /src/app/orders/orders.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { DataService } from '../core/services/data.service'; 4 | import { ICustomer, IPagedResults } from '../shared/interfaces'; 5 | import { TrackByService } from '../core/services/trackby.service'; 6 | import { CapitalizePipe } from '../shared/pipes/capitalize.pipe'; 7 | import { PaginationComponent } from '../shared/pagination/pagination.component'; 8 | import { NgIf, NgFor, CurrencyPipe } from '@angular/common'; 9 | 10 | @Component({ 11 | selector: 'cm-customers-orders', 12 | templateUrl: './orders.component.html', 13 | standalone: true, 14 | imports: [NgIf, NgFor, PaginationComponent, CurrencyPipe, CapitalizePipe] 15 | }) 16 | export class OrdersComponent implements OnInit { 17 | 18 | customers: ICustomer[] = []; 19 | totalRecords = 0; 20 | pageSize = 5; 21 | 22 | constructor(private dataService: DataService, public trackbyService: TrackByService) { } 23 | 24 | ngOnInit() { 25 | this.getCustomersPage(1); 26 | } 27 | 28 | pageChanged(page: number) { 29 | this.getCustomersPage(page); 30 | } 31 | 32 | getCustomersPage(page: number) { 33 | this.dataService.getCustomersPage((page - 1) * this.pageSize, this.pageSize) 34 | .subscribe((response: IPagedResults) => { 35 | this.totalRecords = response.totalRecords; 36 | this.customers = response.results; 37 | }); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/app/routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const ROUTES: Routes = [ 4 | { path: '', pathMatch: 'full', redirectTo: '/customers' }, 5 | { path: 'customers/:id', data: { preload: true }, loadChildren: () => import('./customer/routes').then(m => m.CUSTOMER_ROUTES) }, 6 | { path: 'customers', loadComponent: () => import('./customers/customers.component').then(m => m.CustomersComponent) }, 7 | { path: 'orders', data: { preload: true }, loadComponent: () => import('./orders/orders.component').then(m => m.OrdersComponent) }, 8 | { path: 'about', loadComponent: () => import('./about/about.component').then(m => m.AboutComponent) }, 9 | { path: 'login', loadComponent: () => import('./login/login.component').then(m => m.LoginComponent) }, 10 | { path: '**', pathMatch: 'full', redirectTo: '/customers' } // catch any unfound routes and redirect to home page 11 | ]; -------------------------------------------------------------------------------- /src/app/shared/directives/sortby.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Input, Output, EventEmitter, HostListener } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[cmSortBy]', 5 | standalone: true 6 | }) 7 | export class SortByDirective { 8 | 9 | private sortProperty: string = ''; 10 | 11 | @Output() 12 | sorted: EventEmitter = new EventEmitter(); 13 | 14 | constructor() { } 15 | 16 | @Input('cmSortBy') 17 | set sortBy(value: string) { 18 | this.sortProperty = value; 19 | } 20 | 21 | @HostListener('click', ['$event']) 22 | onClick(e: Event) { 23 | e.preventDefault(); 24 | this.sorted.next(this.sortProperty); // Raise clicked event 25 | } 26 | 27 | 28 | 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/app/shared/filter-textbox/filter-textbox.component.css: -------------------------------------------------------------------------------- 1 | cm-filter-textbox { 2 | margin-top: 5px; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/shared/filter-textbox/filter-textbox.component.html: -------------------------------------------------------------------------------- 1 |
2 | Filter: 3 | 6 |
-------------------------------------------------------------------------------- /src/app/shared/filter-textbox/filter-textbox.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Output, EventEmitter } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | 4 | @Component({ 5 | selector: 'cm-filter-textbox', 6 | templateUrl: './filter-textbox.component.html', 7 | styleUrls: ['./filter-textbox.component.css'], 8 | standalone: true, 9 | imports: [FormsModule] 10 | }) 11 | export class FilterTextboxComponent { 12 | 13 | model: { filter: string } = { filter: '' }; 14 | 15 | @Output() 16 | changed: EventEmitter = new EventEmitter(); 17 | 18 | filterChanged(event: any) { 19 | event.preventDefault(); 20 | this.changed.emit(this.model.filter); // Raise changed event 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { ModuleWithProviders } from '@angular/core'; 2 | import { Routes } from '@angular/router'; 3 | 4 | export interface ICustomer { 5 | id: number; 6 | firstName: string; 7 | lastName: string; 8 | gender: string; 9 | address: string; 10 | city: string; 11 | state: IState; 12 | orders?: IOrder[]; 13 | orderTotal?: number; 14 | latitude?: number; 15 | longitude?: number; 16 | } 17 | 18 | export interface IMapDataPoint { 19 | longitude: number; 20 | latitutde: number; 21 | markerText?: string; 22 | } 23 | 24 | export interface IState { 25 | abbreviation: string; 26 | name: string; 27 | } 28 | 29 | export interface IOrder { 30 | productName: string; 31 | itemCost: number; 32 | } 33 | 34 | export interface IOrderItem { 35 | id: number; 36 | productName: string; 37 | itemCost: number; 38 | } 39 | 40 | export interface IPagedResults { 41 | totalRecords: number; 42 | results: T; 43 | } 44 | 45 | export interface IUserLogin { 46 | email: string; 47 | password: string; 48 | } 49 | 50 | export interface IApiResponse { 51 | status: boolean; 52 | error?: string; 53 | } 54 | -------------------------------------------------------------------------------- /src/app/shared/map/map-point.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'cm-map-point', 5 | template: `` 6 | }) 7 | export class MapPointComponent implements OnInit { 8 | 9 | @Input() longitude: number = 0; 10 | @Input() latitude: number = 0; 11 | @Input() markerText: string = ''; 12 | 13 | constructor() { } 14 | 15 | ngOnInit() { 16 | 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/app/shared/map/map.component.html: -------------------------------------------------------------------------------- 1 |
Map Loading....
-------------------------------------------------------------------------------- /src/app/shared/map/map.component.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | Component, OnInit, AfterContentInit, Input, ViewChild, 5 | ContentChildren, ElementRef, QueryList, ChangeDetectionStrategy 6 | } from '@angular/core'; 7 | 8 | import { debounceTime } from 'rxjs/operators'; 9 | import { MapPointComponent } from './map-point.component'; 10 | import { IMapDataPoint } from '../../shared/interfaces'; 11 | 12 | @Component({ 13 | selector: 'cm-map', 14 | templateUrl: './map.component.html', 15 | // When using OnPush detectors, then the framework will check an OnPush 16 | // component when any of its input properties changes, when it fires 17 | // an event, or when an observable fires an event ~ Victor Savkin (Angular Team) 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | standalone: true 20 | }) 21 | export class MapComponent implements OnInit, AfterContentInit { 22 | private isEnabled: boolean = false; 23 | private loadingScript: boolean = false; 24 | private map: google.maps.Map = {} as google.maps.Map; 25 | private markers: google.maps.Marker[] = []; 26 | mapHeight: string | null = null; 27 | mapWidth: string | null = null; 28 | 29 | @Input() height: number = 0; 30 | @Input() width: number = 0; 31 | @Input() latitude = 34.5133; 32 | @Input() longitude = -94.1629; 33 | @Input() markerText = 'Your Location'; 34 | @Input() zoom = 8; 35 | private _dataPoints: IMapDataPoint[] = []; 36 | @Input() public get dataPoints() { 37 | return this._dataPoints as IMapDataPoint[]; 38 | } 39 | 40 | public set dataPoints(value: any[]) { 41 | this._dataPoints = value; 42 | this.renderMapPoints(); 43 | } 44 | 45 | // Necessary since a map rendered while container is hidden 46 | // will not load the map tiles properly and show a grey screen 47 | @Input() get enabled(): boolean { 48 | return this.isEnabled; 49 | } 50 | 51 | set enabled(isEnabled: boolean) { 52 | this.isEnabled = isEnabled; 53 | this.init(); 54 | } 55 | 56 | @ViewChild('mapContainer', { static: true }) mapDiv: ElementRef = {} as ElementRef; 57 | @ContentChildren(MapPointComponent) mapPoints: QueryList = {} as QueryList; 58 | 59 | ngOnInit() { 60 | if (this.latitude && this.longitude) { 61 | if (this.mapHeight && this.mapWidth) { 62 | this.mapHeight = this.height + 'px'; 63 | this.mapWidth = this.width + 'px'; 64 | } else { 65 | const hw = this.getWindowHeightWidth(this.mapDiv.nativeElement.ownerDocument); 66 | this.mapHeight = hw.height / 2 + 'px'; 67 | this.mapWidth = hw.width + 'px'; 68 | } 69 | } 70 | } 71 | 72 | ngAfterContentInit() { 73 | this.mapPoints.changes 74 | .pipe( 75 | debounceTime(500) 76 | ) 77 | .subscribe(() => { 78 | if (this.enabled) { 79 | this.renderMapPoints(); 80 | } 81 | }); 82 | } 83 | 84 | init() { 85 | // Need slight delay to avoid grey box when google script has previously been loaded. 86 | // Otherwise map
container may not be visible yet which causes the grey box. 87 | setTimeout(() => { 88 | this.ensureScript(); 89 | }, 200); 90 | } 91 | 92 | private getWindowHeightWidth(document: Document) { 93 | let width = window.innerWidth 94 | || document.documentElement.clientWidth 95 | || document.body.clientWidth; 96 | 97 | const height = window.innerHeight 98 | || document.documentElement.clientHeight 99 | || document.body.clientHeight; 100 | 101 | if (width > 900) { width = 900; } 102 | 103 | return { height: height, width: width }; 104 | } 105 | 106 | private ensureScript() { 107 | this.loadingScript = true; 108 | const document = this.mapDiv.nativeElement.ownerDocument; 109 | const script = document.querySelector('script[id="googlemaps"]'); 110 | if (script) { 111 | if (this.isEnabled) { this.renderMap(); } 112 | } else { 113 | const mapsScript = document.createElement('script'); 114 | mapsScript.id = 'googlemaps'; 115 | mapsScript.type = 'text/javascript'; 116 | mapsScript.async = true; 117 | mapsScript.defer = true; 118 | mapsScript.src = 'https://maps.googleapis.com/maps/api/js?key='; 119 | mapsScript.onload = () => { 120 | this.loadingScript = false; 121 | if (this.isEnabled) { this.renderMap(); } 122 | }; 123 | document.body.appendChild(mapsScript); 124 | } 125 | } 126 | 127 | private renderMap() { 128 | const latlng = this.createLatLong(this.latitude, this.longitude) as google.maps.LatLng; 129 | const options = { 130 | zoom: this.zoom, 131 | center: latlng, 132 | mapTypeControl: true, 133 | mapTypeId: google.maps.MapTypeId.ROADMAP 134 | } as google.maps.MapOptions; 135 | 136 | this.map = new google.maps.Map(this.mapDiv.nativeElement, options); 137 | 138 | // See if we have any mapPoints (child content) or dataPoints (@Input property) 139 | if ((this.mapPoints && this.mapPoints.length) || (this.dataPoints && this.dataPoints.length)) { 140 | this.renderMapPoints(); 141 | } else { 142 | this.createMarker(latlng, this.markerText); 143 | } 144 | } 145 | 146 | private createLatLong(latitude: number, longitude: number) { 147 | return (latitude && longitude) ? new google.maps.LatLng(latitude, longitude) : null; 148 | } 149 | 150 | private renderMapPoints() { 151 | if (this.map && this.isEnabled) { 152 | this.clearMapPoints(); 153 | 154 | // lon/lat can be passed as child content or via the dataPoints @Input property 155 | const mapPoints = (this.mapPoints && this.mapPoints.length) ? this.mapPoints : this.dataPoints; 156 | 157 | if (mapPoints) { 158 | for (const point of mapPoints) { 159 | let markerText = (point.markerText) ? point.markerText : `

${point.firstName} ${point.lastName}

`; 160 | const mapPointLatlng = this.createLatLong(point.latitude, point.longitude) as google.maps.LatLng; 161 | this.createMarker(mapPointLatlng, markerText); 162 | } 163 | } 164 | } 165 | } 166 | 167 | private clearMapPoints() { 168 | this.markers.forEach((marker: google.maps.Marker) => { 169 | marker.setMap(null); 170 | }); 171 | this.markers = []; 172 | } 173 | 174 | private createMarker(position: google.maps.LatLng, title: string) { 175 | const infowindow = new google.maps.InfoWindow({ 176 | content: title 177 | }); 178 | 179 | const marker = new google.maps.Marker({ 180 | position: position, 181 | map: this.map, 182 | title: title, 183 | animation: google.maps.Animation.DROP 184 | }); 185 | 186 | this.markers.push(marker); 187 | 188 | marker.addListener('click', () => { 189 | infowindow.open(this.map, marker); 190 | }); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/app/shared/pagination/pagination.component.css: -------------------------------------------------------------------------------- 1 | .pagination>.active>a, 2 | .pagination>.active>a:focus, 3 | .pagination>.active>a:hover, 4 | .pagination>.active>span, 5 | .pagination>.active>span:focus, 6 | .pagination>.active>span:hover { 7 | background-color: #027FF4; 8 | border-color: #027FF4; 9 | } 10 | 11 | .pagination a { 12 | cursor: pointer; 13 | } -------------------------------------------------------------------------------- /src/app/shared/pagination/pagination.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/pagination/pagination.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; 2 | import { NgFor } from '@angular/common'; 3 | 4 | @Component({ 5 | selector: 'cm-pagination', 6 | templateUrl: './pagination.component.html', 7 | styleUrls: ['./pagination.component.css'], 8 | standalone: true, 9 | imports: [NgFor] 10 | }) 11 | 12 | export class PaginationComponent implements OnInit { 13 | 14 | private pagerTotalItems: number = 0; 15 | private pagerPageSize: number = 0; 16 | 17 | totalPages: number = 0; 18 | pages: number[] = []; 19 | currentPage = 1; 20 | isVisible = false; 21 | previousEnabled = false; 22 | nextEnabled = true; 23 | 24 | @Input() get pageSize(): number { 25 | return this.pagerPageSize; 26 | } 27 | 28 | set pageSize(size: number) { 29 | this.pagerPageSize = size; 30 | this.update(); 31 | } 32 | 33 | @Input() get totalItems(): number { 34 | return this.pagerTotalItems; 35 | } 36 | 37 | set totalItems(itemCount: number) { 38 | this.pagerTotalItems = itemCount; 39 | this.update(); 40 | } 41 | 42 | @Output() pageChanged: EventEmitter = new EventEmitter(); 43 | 44 | constructor() { } 45 | 46 | ngOnInit() { 47 | 48 | } 49 | 50 | update() { 51 | if (this.pagerTotalItems && this.pagerPageSize) { 52 | this.totalPages = Math.ceil(this.pagerTotalItems / this.pageSize); 53 | this.isVisible = true; 54 | if (this.totalItems >= this.pageSize) { 55 | for (let i = 1; i < this.totalPages + 1; i++) { 56 | this.pages.push(i); 57 | } 58 | } 59 | return; 60 | } 61 | 62 | this.isVisible = false; 63 | } 64 | 65 | previousNext(direction: number, event?: MouseEvent) { 66 | let page: number = this.currentPage; 67 | if (direction === -1) { 68 | if (page > 1) { page--; } 69 | } else { 70 | if (page < this.totalPages) { page++; } 71 | } 72 | this.changePage(page, event); 73 | } 74 | 75 | changePage(page: number, event?: MouseEvent) { 76 | if (event) { 77 | event.preventDefault(); 78 | } 79 | if (this.currentPage === page) { return; } 80 | this.currentPage = page; 81 | this.previousEnabled = this.currentPage > 1; 82 | this.nextEnabled = this.currentPage < this.totalPages; 83 | this.pageChanged.emit(page); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/app/shared/pipes/capitalize.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'capitalize', 5 | standalone: true 6 | }) 7 | export class CapitalizePipe implements PipeTransform { 8 | 9 | transform(value: any) { 10 | return typeof value === 'string' && value.charAt(0).toUpperCase() + value.slice(1) || value; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/shared/pipes/trim.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'trim', 5 | standalone: true 6 | }) 7 | export class TrimPipe implements PipeTransform { 8 | transform(value: any) { 9 | if (!value) { 10 | return ''; 11 | } 12 | return value.trim(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/shared/router.animations.ts: -------------------------------------------------------------------------------- 1 | import { trigger, state, animate, style, transition } from '@angular/animations'; 2 | 3 | // Post by Gerard Sans: https://medium.com/google-developer-experts/angular-2-animate-router-transitions-6de179e00204#.7h2femijg 4 | 5 | // Add into index.html for polyfill 6 | // Add the following to any component to animate the view 7 | // import { routerTransition } from './router.animations'; 8 | 9 | // @Component({ 10 | // selector: 'home', 11 | // template: `

Home

`, 12 | // animations: [routerTransition()], 13 | // host: {'[@routerTransition]': ''} 14 | // }) 15 | 16 | export function routerTransition() { 17 | return slideToLeft(); 18 | } 19 | 20 | function slideToRight() { 21 | return trigger('routerTransition', [ 22 | state('void', style({position: 'fixed', width: '40%'}) ), 23 | state('*', style({position: 'fixed', width: '0%'}) ), 24 | transition(':enter', [ 25 | style({transform: 'translateX(-40%)'}), 26 | animate('0.5s ease-in-out', style({transform: 'translateX(0%)'})) 27 | ]), 28 | transition(':leave', [ 29 | style({transform: 'translateX(0%)'}), 30 | animate('0.5s ease-in-out', style({transform: 'translateX(40%)'})) 31 | ]) 32 | ]); 33 | } 34 | 35 | function slideToLeft() { 36 | return trigger('routerTransition', [ 37 | state('void', style({position: 'fixed', width: '40%'}) ), 38 | state('*', style({position: 'fixed', width: '0%'}) ), 39 | transition(':enter', [ 40 | style({transform: 'translateX(40%)'}), 41 | animate('0.5s ease-in-out', style({transform: 'translateX(0%)'})) 42 | ]), 43 | transition(':leave', [ 44 | style({transform: 'translateX(0%)'}), 45 | animate('0.5s ease-in-out', style({transform: 'translateX(-40%)'})) 46 | ]) 47 | ]); 48 | } 49 | 50 | function slideToBottom() { 51 | return trigger('routerTransition', [ 52 | state('void', style({position: 'fixed', width: '100%', height: '100%'}) ), 53 | state('*', style({position: 'fixed', width: '100%', height: '100%'}) ), 54 | transition(':enter', [ 55 | style({transform: 'translateY(-100%)'}), 56 | animate('0.5s ease-in-out', style({transform: 'translateY(0%)'})) 57 | ]), 58 | transition(':leave', [ 59 | style({transform: 'translateY(0%)'}), 60 | animate('0.5s ease-in-out', style({transform: 'translateY(100%)'})) 61 | ]) 62 | ]); 63 | } 64 | 65 | function slideToTop() { 66 | return trigger('routerTransition', [ 67 | state('void', style({position: 'fixed', width: '100%', height: '100%'}) ), 68 | state('*', style({position: 'fixed', width: '100%', height: '100%'}) ), 69 | transition(':enter', [ 70 | style({transform: 'translateY(100%)'}), 71 | animate('0.5s ease-in-out', style({transform: 'translateY(0%)'})) 72 | ]), 73 | transition(':leave', [ 74 | style({transform: 'translateY(0%)'}), 75 | animate('0.5s ease-in-out', style({transform: 'translateY(-100%)'})) 76 | ]) 77 | ]); 78 | } 79 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | Angular TypeScript JumpStart App 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Loading... 26 | 27 | 28 |
29 | 42 |
43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { importProvidersFrom } from '@angular/core'; 2 | import { withInterceptorsFromDi, provideHttpClient, HTTP_INTERCEPTORS } from '@angular/common/http'; 3 | import { provideAnimations } from '@angular/platform-browser/animations'; 4 | import { AppComponent } from './app/app.component'; 5 | import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; 6 | import { RouterModule } from '@angular/router'; 7 | import { ROUTES } from './app/routes'; 8 | import { PreloadModulesStrategy } from './app/core/strategies/preload-modules.strategy'; 9 | import { AuthInterceptor } from './app/core/interceptors/auth.interceptor'; 10 | import { OverlayRequestResponseInterceptor } from './app/core/overlay/overlay-request-response.interceptor'; 11 | 12 | bootstrapApplication(AppComponent, { 13 | providers: [ 14 | importProvidersFrom(BrowserModule, 15 | RouterModule.forRoot(ROUTES, { preloadingStrategy: PreloadModulesStrategy })), 16 | { 17 | provide: HTTP_INTERCEPTORS, 18 | useClass: AuthInterceptor, 19 | multi: true, 20 | }, 21 | { 22 | provide: HTTP_INTERCEPTORS, 23 | useClass: OverlayRequestResponseInterceptor, 24 | multi: true, 25 | }, 26 | { provide: 'Window', useFactory: () => window }, 27 | provideAnimations(), 28 | provideHttpClient(withInterceptorsFromDi()), 29 | ] 30 | }) 31 | .catch(err => console.error(err)); 32 | -------------------------------------------------------------------------------- /src/stories/About.stories.ts: -------------------------------------------------------------------------------- 1 | import { moduleMetadata } from '@storybook/angular'; 2 | import { CommonModule } from '@angular/common'; 3 | // also exported from '@storybook/angular' if you can deal with breaking changes in 6.1 4 | import { Story, Meta } from '@storybook/angular/types-6-0'; 5 | 6 | import { AboutComponent } from '../app/about/about.component'; 7 | 8 | export default { 9 | title: 'Example/About', 10 | component: AboutComponent, 11 | decorators: [ 12 | moduleMetadata({ 13 | imports: [CommonModule], 14 | }), 15 | ], 16 | } as Meta; 17 | 18 | const Template: Story = (args: AboutComponent) => ({ 19 | component: AboutComponent, 20 | props: args, 21 | }); 22 | 23 | export const About = Template.bind({}); 24 | About.args = {}; 25 | -------------------------------------------------------------------------------- /src/stories/Button.stories.ts: -------------------------------------------------------------------------------- 1 | // also exported from '@storybook/angular' if you can deal with breaking changes in 6.1 2 | import { Story, Meta } from '@storybook/angular/types-6-0'; 3 | import Button from './button.component'; 4 | 5 | // More on default export: https://storybook.js.org/docs/angular/writing-stories/introduction#default-export 6 | export default { 7 | title: 'Example/Button', 8 | component: Button, 9 | // More on argTypes: https://storybook.js.org/docs/angular/api/argtypes 10 | argTypes: { 11 | backgroundColor: { control: 'color' }, 12 | }, 13 | } as Meta; 14 | 15 | // More on component templates: https://storybook.js.org/docs/angular/writing-stories/introduction#using-args 16 | const Template: Story`, 13 | styleUrls: ['./button.css'], 14 | }) 15 | export default class ButtonComponent { 16 | /** 17 | * Is this the principal call to action on the page? 18 | */ 19 | @Input() 20 | primary = false; 21 | 22 | /** 23 | * What background color to use 24 | */ 25 | @Input() 26 | backgroundColor?: string; 27 | 28 | /** 29 | * How large should the button be? 30 | */ 31 | @Input() 32 | size: 'small' | 'medium' | 'large' = 'medium'; 33 | 34 | /** 35 | * Button contents 36 | * 37 | * @required 38 | */ 39 | @Input() 40 | label = 'Button'; 41 | 42 | /** 43 | * Optional click handler 44 | */ 45 | @Output() 46 | onClick = new EventEmitter(); 47 | 48 | public get classes(): string[] { 49 | const mode = this.primary ? 'storybook-button--primary' : 'storybook-button--secondary'; 50 | 51 | return ['storybook-button', `storybook-button--${this.size}`, mode]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { User } from './User'; 3 | 4 | @Component({ 5 | selector: 'storybook-header', 6 | template: `
7 |
8 |
9 | 10 | 11 | 15 | 19 | 23 | 24 | 25 |

Acme

26 |
27 |
28 | 34 | 40 | 47 |
48 |
49 |
`, 50 | styleUrls: ['./header.css'], 51 | }) 52 | export default class HeaderComponent { 53 | @Input() 54 | user: User | null = null; 55 | 56 | @Output() 57 | onLogin = new EventEmitter(); 58 | 59 | @Output() 60 | onLogout = new EventEmitter(); 61 | 62 | @Output() 63 | onCreateAccount = new EventEmitter(); 64 | } 65 | -------------------------------------------------------------------------------- /src/stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 900; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | -------------------------------------------------------------------------------- /src/stories/page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; 2 | import { User } from './User'; 3 | 4 | @Component({ 5 | selector: 'storybook-page', 6 | template: `
7 | 13 |
14 |

Pages in Storybook

15 |

16 | We recommend building UIs with a 17 | 18 | component-driven 19 | 20 | process starting with atomic components and ending with pages. 21 |

22 |

23 | Render pages with mock data. This makes it easy to build and review page states without 24 | needing to navigate to them in your app. Here are some handy patterns for managing page data 25 | in Storybook: 26 |

27 |
    28 |
  • 29 | Use a higher-level connected component. Storybook helps you compose such data from the 30 | "args" of child component stories 31 |
  • 32 |
  • 33 | Assemble data in the page component from your services. You can mock these services out 34 | using Storybook. 35 |
  • 36 |
37 |

38 | Get a guided tutorial on component-driven development at 39 | 40 | Storybook tutorials 41 | 42 | . Read more in the 43 | docs 44 | . 45 |

46 |
47 | Tip Adjust the width of the canvas with the 48 | 49 | 50 | 55 | 56 | 57 | Viewports addon in the toolbar 58 |
59 |
60 |
`, 61 | styleUrls: ['./page.css'], 62 | }) 63 | export default class PageComponent { 64 | @Input() 65 | user: User | null = null; 66 | 67 | @Output() 68 | onLogin = new EventEmitter(); 69 | 70 | @Output() 71 | onLogout = new EventEmitter(); 72 | 73 | @Output() 74 | onCreateAccount = new EventEmitter(); 75 | } 76 | 77 | // export const Page = ({ user, onLogin, onLogout, onCreateAccount }) => ( 78 | //
79 | //
80 | 81 | // ); 82 | // Page.propTypes = { 83 | // user: PropTypes.shape({}), 84 | // onLogin: PropTypes.func.isRequired, 85 | // onLogout: PropTypes.func.isRequired, 86 | // onCreateAccount: PropTypes.func.isRequired, 87 | // }; 88 | 89 | // Page.defaultProps = { 90 | // user: null, 91 | // }; 92 | -------------------------------------------------------------------------------- /src/stories/page.css: -------------------------------------------------------------------------------- 1 | section { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 14px; 4 | line-height: 24px; 5 | padding: 48px 20px; 6 | margin: 0 auto; 7 | max-width: 600px; 8 | color: #333; 9 | } 10 | 11 | section h2 { 12 | font-weight: 900; 13 | font-size: 32px; 14 | line-height: 1; 15 | margin: 0 0 4px; 16 | display: inline-block; 17 | vertical-align: top; 18 | } 19 | 20 | section p { 21 | margin: 1em 0; 22 | } 23 | 24 | section a { 25 | text-decoration: none; 26 | color: #1ea7fd; 27 | } 28 | 29 | section ul { 30 | padding-left: 30px; 31 | margin: 1em 0; 32 | } 33 | 34 | section li { 35 | margin-bottom: 8px; 36 | } 37 | 38 | section .tip { 39 | display: inline-block; 40 | border-radius: 1em; 41 | font-size: 11px; 42 | line-height: 12px; 43 | font-weight: 700; 44 | background: #e7fdd8; 45 | color: #66bf3c; 46 | padding: 4px 12px; 47 | margin-right: 10px; 48 | vertical-align: top; 49 | } 50 | 51 | section .tip-wrapper { 52 | font-size: 13px; 53 | line-height: 20px; 54 | margin-top: 40px; 55 | margin-bottom: 40px; 56 | } 57 | 58 | section .tip-wrapper svg { 59 | display: inline-block; 60 | height: 12px; 61 | width: 12px; 62 | margin-right: 4px; 63 | vertical-align: top; 64 | margin-top: 3px; 65 | } 66 | 67 | section .tip-wrapper svg path { 68 | fill: #1ea7fd; 69 | } 70 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow-y: scroll; 3 | overflow-x: hidden; 4 | } 5 | 6 | body { 7 | font-family: 'Open Sans' 8 | } 9 | 10 | main { 11 | position: relative; 12 | padding-top: 60px; 13 | } 14 | 15 | /* Ensure display:flex and others don't override a [hidden] */ 16 | [hidden] { 17 | display: none !important; 18 | } 19 | 20 | th { 21 | cursor: pointer; 22 | } 23 | 24 | .app-title { 25 | line-height:50px; 26 | font-size:20px; 27 | color: white; 28 | } 29 | 30 | .toolbar-item a { 31 | cursor: pointer; 32 | } 33 | 34 | .orders-table { 35 | width:50%; 36 | } 37 | 38 | .orders-table td { 39 | width: 50%; 40 | } 41 | 42 | .summary-border { 43 | border-top: 2px solid black; 44 | } 45 | 46 | .indent { 47 | margin-left:5px; 48 | } 49 | 50 | .navbar { 51 | min-height: 60px; 52 | } 53 | 54 | .navbar .nav > li.toolbar-item > a { 55 | color: #9E9E9E; 56 | font-weight:bold; 57 | } 58 | 59 | .navbar .nav > .toolbar-item > a.active { 60 | color: #000; 61 | } 62 | 63 | .navbar-header { 64 | margin-top: -10px; 65 | } 66 | 67 | .navbar-collapse { 68 | float:right; 69 | margin-top: 15px; 70 | } 71 | 72 | .nav.navbar-padding { 73 | margin-left:25px; 74 | margin-top: 10px; 75 | } 76 | 77 | a.navbar-brand { 78 | color: #fff; 79 | } 80 | 81 | .navbar-brand>img { 82 | display: inline-block; 83 | } 84 | 85 | .navbar-inner { 86 | padding-left: 0px; 87 | background-color: #027FF4; 88 | } 89 | 90 | .navbar-inner.toolbar { 91 | background-color: #fafafa; 92 | } 93 | 94 | .navbar-inner.footer { 95 | background-color: #fafafa; 96 | height:50px; 97 | } 98 | 99 | .navbar .nav > .active > a, .navbar .nav > .active > a:hover, .navbar .nav > .active > a:focus { 100 | background-color: #efefef; 101 | color: #808080; 102 | } 103 | 104 | .navbar .nav li.toolbaritem a:hover, .navbar .nav li a:hover { 105 | color: #E03930; 106 | } 107 | 108 | .navbar .nav > li { 109 | cursor:pointer; 110 | } 111 | 112 | .navbar .nav > li > a { 113 | color: white; 114 | font-weight:bold; 115 | height:30px; 116 | padding-top: 6px; 117 | padding-bottom: 0px; 118 | } 119 | 120 | .navbar .nav > li.toolbaritem > a { 121 | color: black; 122 | font-weight:bold; 123 | } 124 | 125 | .nav.navBarPadding { 126 | margin-left: 25px; 127 | margin-top: 10px; 128 | } 129 | 130 | .navbarText { 131 | font-weight:bold; 132 | } 133 | 134 | .navbar-toggle { 135 | border: 1px solid white; 136 | } 137 | 138 | .navbar-toggle .icon-bar { 139 | background-color: white; 140 | } 141 | 142 | .navbar-inner.footer { 143 | background-color: #fafafa; 144 | height:50px; 145 | } 146 | 147 | footer { 148 | margin-top: 15px; 149 | } 150 | 151 | form { 152 | width:50%; 153 | } 154 | 155 | 156 | a:hover, a:focus { 157 | text-decoration: none; 158 | } 159 | 160 | .spinner { 161 | height:60px; 162 | width:60px; 163 | -webkit-animation: rotation .6s infinite linear; 164 | -moz-animation: rotation .6s infinite linear; 165 | -o-animation: rotation .6s infinite linear; 166 | animation: rotation .6s infinite linear; 167 | border-left:6px solid rgba(0,174,239,.15); 168 | border-right:6px solid rgba(0,174,239,.15); 169 | border-bottom:6px solid rgba(0,174,239,.15); 170 | border-top:6px solid rgba(0,174,239,.8); 171 | border-radius:100%; 172 | } 173 | 174 | @-webkit-keyframes rotation { 175 | from {-webkit-transform: rotate(0deg);} 176 | to {-webkit-transform: rotate(359deg);} 177 | } 178 | @-moz-keyframes rotation { 179 | from {-moz-transform: rotate(0deg);} 180 | to {-moz-transform: rotate(359deg);} 181 | } 182 | @-o-keyframes rotation { 183 | from {-o-transform: rotate(0deg);} 184 | to {-o-transform: rotate(359deg);} 185 | } 186 | @keyframes rotation { 187 | from {transform: rotate(0deg);} 188 | to {transform: rotate(359deg);} 189 | } 190 | 191 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/types/importMeta.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMeta { 2 | readonly env: { 3 | [key: string]: string | undefined; 4 | NG_APP_API_URL: string; 5 | }; 6 | } -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [] 8 | }, 9 | "files": [ 10 | "src/main.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "outDir": "./dist/out-tsc", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "sourceMap": true, 16 | "declaration": false, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "bundler", 19 | "importHelpers": true, 20 | "target": "ES2022", 21 | "module": "ES2022", 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } --------------------------------------------------------------------------------