├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── update_dependencies.yml ├── .gitignore ├── .mergify.yml ├── .npmrc ├── README.md ├── angular.json ├── cypress.config.ts ├── cypress ├── e2e │ └── sample_app.cy.js ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ ├── commands.js │ └── e2e.js ├── dependencies.yml ├── karma.conf.js ├── package.json ├── protractor.conf.js ├── src ├── app │ ├── README.md │ ├── app.component.ts │ ├── app.module.ts │ ├── app.states.ts │ ├── contacts │ │ ├── README.md │ │ ├── contact-detail.component.ts │ │ ├── contact-list.component.ts │ │ ├── contact.component.ts │ │ ├── contacts-data.service.ts │ │ ├── contacts.component.ts │ │ ├── contacts.module.ts │ │ ├── contacts.states.ts │ │ └── edit-contact.component.ts │ ├── global │ │ ├── README.md │ │ ├── app-config.service.ts │ │ ├── auth.hook.ts │ │ ├── auth.service.ts │ │ ├── dialog.component.ts │ │ ├── dialog.service.ts │ │ └── global.module.ts │ ├── home.component.ts │ ├── login.component.ts │ ├── main.component.ts │ ├── mymessages │ │ ├── compose.component.ts │ │ ├── folder-list.component.ts │ │ ├── folders-data.service.ts │ │ ├── format-message.pipe.ts │ │ ├── interface.ts │ │ ├── message-list.component.ts │ │ ├── message-table.component.ts │ │ ├── message.component.ts │ │ ├── messages-data.service.ts │ │ ├── mymessages.component.ts │ │ ├── mymessages.module.ts │ │ ├── mymessages.states.ts │ │ └── sort-messages.component.ts │ ├── prefs │ │ ├── README.md │ │ ├── prefs.component.ts │ │ ├── prefs.module.ts │ │ └── prefs.states.ts │ ├── router.config.ts │ ├── util │ │ ├── README.md │ │ ├── ga.ts │ │ ├── sessionStorage.ts │ │ └── util.ts │ └── welcome.component.ts ├── assets │ ├── .gitkeep │ ├── README.md │ ├── contacts.json │ ├── corpora │ │ ├── 2nd-treatise.txt.gz │ │ ├── beatles.txt.gz │ │ ├── beowulf.txt.gz │ │ ├── bsdfaq.txt.gz │ │ ├── cat-in-the-hat.txt.gz │ │ ├── comm_man.txt.gz │ │ ├── elflore.txt.gz │ │ ├── flatland.txt.gz │ │ ├── green-eggs.txt.gz │ │ ├── macbeth.txt.gz │ │ ├── palin.txt.gz │ │ ├── rfc2549.txt.gz │ │ ├── rfc7230.txt.gz │ │ ├── sneetches.txt.gz │ │ └── two-cities.txt.gz │ ├── fetch.sh │ ├── folders.json │ ├── generate.js │ ├── generate.sh │ └── messages.json ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.css ├── test.ts └── tsconfig.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://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 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'CI' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | 11 | ci: 12 | needs: [test] 13 | runs-on: ubuntu-latest 14 | steps: 15 | - run: true 16 | 17 | test: 18 | name: yarn ${{ matrix.yarncmd }} 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | yarncmd: ['test'] 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Configure 26 | run: | 27 | git config --global user.email uirouter@github.actions 28 | git config --global user.name uirouter_github_actions 29 | - name: Install Dependencies 30 | run: yarn install --pure-lockfile 31 | #- name: Check Peer Dependencies 32 | # run: npx check-peer-dependencies 33 | - name: Run Tests 34 | run: yarn ${{ matrix.yarncmd }} 35 | -------------------------------------------------------------------------------- /.github/workflows/update_dependencies.yml: -------------------------------------------------------------------------------- 1 | # This workflow requires a personal access token for uirouterbot 2 | name: Weekly Dependency Bumps 3 | on: 4 | repository_dispatch: 5 | types: [update_dependencies] 6 | schedule: 7 | - cron: '0 19 * * 0' 8 | 9 | jobs: 10 | upgrade: 11 | runs-on: ubuntu-latest 12 | name: 'Update ${{ matrix.deptype }} (latest: ${{ matrix.latest }})' 13 | strategy: 14 | matrix: 15 | excludes: ['typescript,core-js'] 16 | deptype: ['dependencies', 'devDependencies'] 17 | latest: [true] 18 | steps: 19 | - uses: actions/checkout@v2 20 | - run: | 21 | git config user.name uirouterbot 22 | git config user.password ${{ secrets.UIROUTERBOT_PAT }} 23 | git remote set-url origin $(git remote get-url origin | sed -e 's/ui-router/uirouterbot/') 24 | git fetch --unshallow -p origin 25 | - name: Update dependencies 26 | id: upgrade 27 | uses: ui-router/publish-scripts/actions/upgrade@actions-upgrade-v1.0.3 28 | with: 29 | excludes: ${{ matrix.excludes }} 30 | deptype: ${{ matrix.deptype }} 31 | latest: ${{ matrix.latest }} 32 | - name: Create Pull Request 33 | id: cpr 34 | if: ${{ steps.upgrade.outputs.upgrades != '' }} 35 | # the following hash is from https://github.com/peter-evans/create-pull-request/releases/tag/v2.7.0 36 | uses: peter-evans/create-pull-request@340e629d2f63059fb3e3f15437e92cfbc7acd85b 37 | with: 38 | token: ${{ secrets.UIROUTERBOT_PAT }} 39 | request-to-parent: true 40 | branch-suffix: 'random' 41 | commit-message: 'chore(package): Update ${{ steps.upgrade.outputs.upgradecount }} ${{ matrix.deptype }} to ${{ steps.upgrade.outputs.upgradestrategy }}' 42 | title: 'chore(package): Update ${{ steps.upgrade.outputs.upgradecount }} ${{ matrix.deptype }} to ${{ steps.upgrade.outputs.upgradestrategy }}' 43 | body: | 44 | chore(package): Update ${{ steps.upgrade.outputs.upgradecount }} ${{ matrix.deptype }} to ${{ steps.upgrade.outputs.upgradestrategy }} 45 | 46 | ``` 47 | ${{ steps.upgrade.outputs.upgrades }} 48 | ``` 49 | 50 | Auto-generated by [create-pull-request][1] 51 | 52 | [1]: https://github.com/peter-evans/create-pull-request 53 | - name: Apply Merge Label 54 | if: ${{ steps.cpr.outputs.pr_number != '' }} 55 | uses: actions/github-script@0.9.0 56 | with: 57 | github-token: ${{ secrets.UIROUTERBOT_PAT }} 58 | script: | 59 | await github.issues.addLabels({ 60 | owner: context.repo.owner, 61 | repo: context.repo.repo, 62 | issue_number: ${{ steps.cpr.outputs.pr_number }}, 63 | labels: ['ready to squash and merge'] 64 | }); 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | yarn-error.log 3 | 4 | # compiled output 5 | /dist 6 | /tmp 7 | .* 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | *.launch 14 | 15 | # IDE - VSCode 16 | .vscode/* 17 | !.vscode/settings.json 18 | !.vscode/tasks.json 19 | !.vscode/launch.json 20 | !.vscode/extensions.json 21 | 22 | # misc 23 | /.angular/cache 24 | /connect.lock 25 | /coverage/* 26 | /libpeerconnection.log 27 | npm-debug.log 28 | testem.log 29 | /typings 30 | 31 | # e2e 32 | /e2e/*.js 33 | /e2e/*.map 34 | 35 | #System Files 36 | Thumbs.db 37 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: default 3 | conditions: 4 | - check-success=ci 5 | 6 | 7 | pull_request_rules: 8 | - name: Auto Squash and Merge 9 | conditions: 10 | - base=master 11 | - status-success=ci 12 | - 'label=ready to squash and merge' 13 | actions: 14 | delete_head_branch: {} 15 | queue: 16 | method: squash 17 | name: default 18 | - name: Auto Rebase and Merge 19 | conditions: 20 | - base=master 21 | - status-success=ci 22 | - 'label=ready to rebase and merge' 23 | actions: 24 | delete_head_branch: {} 25 | queue: 26 | method: rebase 27 | name: default 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## UI-Router for Angular - Sample Application 2 | 3 | [![Travis badge](https://travis-ci.org/ui-router/sample-app-angular.svg?branch=master)](https://travis-ci.org/ui-router/sample-app-angular) 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/ui-router/sample-app-angular.svg)](https://greenkeeper.io/) 5 | 6 | This project was generated with [angular-cli](https://github.com/angular/angular-cli) version 1.0.0-beta.30. 7 | 8 | Live demo: http://ui-router.github.io/sample-app-angular/#/mymessages/inbox/5648b50cc586cac4aed6836f 9 | 10 | Edit this project in your browser: https://stackblitz.com/github/ui-router/sample-app-angular 11 | 12 | This sample app is intended to demonstrate a non-trivial ui-router application. 13 | 14 | - Multiple sub-modules 15 | - Managed state lifecycle 16 | - Application data lifecycle 17 | - Authentication (simulated) 18 | - Authenticated and unauthenticated states 19 | - REST data retrieval (simulated) 20 | - Lazy loaded Angular modules (contacts/mymessages/prefs submodules) 21 | 22 | --- 23 | 24 | ### Visualizer 25 | 26 | We're using the [State and Transition Visualizer](http://github.com/ui-router/visualizer) to visually represent 27 | the current state tree, as well as the transitions between states. 28 | Explore how transitions work by hovering over them, and clicking to expand details (params and resolves). 29 | 30 | Note how states are _entered_ when they were previously not active, _exited_ and re-_entered_ when parameters change, 31 | and how parent states whose parameters did not change are _retained_. 32 | Each of these (_exited, entered, retained_) correspond to a Transition Hook. 33 | 34 | ### Structure 35 | 36 | There are many ways to structure a ui-router app. 37 | We aren't super opinionated on application structure. 38 | Use what works for you. 39 | We organized ours in the following way: 40 | 41 | - Feature modules 42 | - Each feature gets its own directory and Angular Module (`@NgModule`) 43 | - Features contain states and components 44 | - Specific types of helper code (directives, services, etc) _used only within a feature_ may live in a subdirectory 45 | named after its type 46 | - Leveraging ES6 modules 47 | - States for a module are defined in separate file 48 | - Each component is defined in its own file 49 | - Components are referenced in states where they are composed into the state definition 50 | - States export themselves 51 | - Each feature `@NgModule` imports and declares all the states for the feature 52 | 53 | ### UI-Router Patterns 54 | 55 | - Defining custom, app-specific global behaviors 56 | - Add metadata to a state, or state tree (such as `authRequired`) 57 | - Check for metadata in transition hooks 58 | - Example: `routerhooks/authRequired.js` 59 | - If a transition to a state with a truthy `data.authRequired: true` property is started and the user is not currently authenticated 60 | - Defining a default substate for a top-level state 61 | - Example: declaring `redirectTo: 'welcome'` in `app.states.ts` 62 | - Defining a default parameter for a state 63 | - Example: `folderId` parameter defaults to 'inbox' in `mymessages.states.ts` (folder state) 64 | - Application data lifecycle 65 | - Data loading is managed by the state declaration, via the `resolve:` block 66 | - Data is fetched before the state is _entered_ 67 | - Data is fetched according to state parameters 68 | - The state is _entered_ when the data is ready 69 | - The resolved data is injected into the components 70 | - The resolve data remains loaded until the state is exited 71 | - Lazy Loaded states 72 | - The main submodules (and all their states and components) are lazy loaded 73 | - The future state includes a `loadChildren` property which is used to lazy load the module 74 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "sample-app-angular": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:application", 13 | "options": { 14 | "outputPath": { 15 | "base": "dist" 16 | }, 17 | "index": "src/index.html", 18 | "tsConfig": "src/tsconfig.json", 19 | "polyfills": [ 20 | "src/polyfills.ts" 21 | ], 22 | "assets": [ 23 | "src/assets", 24 | "src/favicon.ico" 25 | ], 26 | "styles": [ 27 | "src/styles.css" 28 | ], 29 | "scripts": [], 30 | "extractLicenses": false, 31 | "sourceMap": true, 32 | "optimization": false, 33 | "namedChunks": true, 34 | "browser": "src/main.ts" 35 | }, 36 | "configurations": { 37 | "production": { 38 | "budgets": [ 39 | { 40 | "type": "anyComponentStyle", 41 | "maximumWarning": "6kb" 42 | } 43 | ], 44 | "optimization": true, 45 | "outputHashing": "all", 46 | "sourceMap": false, 47 | "namedChunks": false, 48 | "extractLicenses": true, 49 | "fileReplacements": [ 50 | { 51 | "replace": "src/environments/environment.ts", 52 | "with": "src/environments/environment.prod.ts" 53 | } 54 | ] 55 | } 56 | }, 57 | "defaultConfiguration": "" 58 | }, 59 | "serve": { 60 | "builder": "@angular-devkit/build-angular:dev-server", 61 | "options": { 62 | "buildTarget": "sample-app-angular:build" 63 | }, 64 | "configurations": { 65 | "production": { 66 | "buildTarget": "sample-app-angular:build:production" 67 | } 68 | } 69 | }, 70 | "extract-i18n": { 71 | "builder": "@angular-devkit/build-angular:extract-i18n", 72 | "options": { 73 | "buildTarget": "sample-app-angular:build" 74 | } 75 | }, 76 | "test": { 77 | "builder": "@angular-devkit/build-angular:karma", 78 | "options": { 79 | "main": "src/test.ts", 80 | "karmaConfig": "./karma.conf.js", 81 | "polyfills": "src/polyfills.ts", 82 | "scripts": [], 83 | "styles": [ 84 | "src/styles.css" 85 | ], 86 | "assets": [ 87 | "src/assets", 88 | "src/favicon.ico" 89 | ] 90 | } 91 | } 92 | } 93 | }, 94 | "sample-app-angular-e2e": { 95 | "root": "", 96 | "sourceRoot": "", 97 | "projectType": "application", 98 | "architect": { 99 | "e2e": { 100 | "builder": "@angular-devkit/build-angular:protractor", 101 | "options": { 102 | "protractorConfig": "./protractor.conf.js", 103 | "devServerTarget": "sample-app-angular:serve" 104 | } 105 | } 106 | } 107 | } 108 | }, 109 | "schematics": { 110 | "@schematics/angular:component": { 111 | "prefix": "app", 112 | "style": "css" 113 | }, 114 | "@schematics/angular:directive": { 115 | "prefix": "app" 116 | } 117 | }, 118 | "cli": { 119 | "analytics": false 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | video: false, 5 | e2e: { 6 | // We've imported your old cypress plugins here. 7 | // You may want to clean this up later by importing these. 8 | setupNodeEvents(on, config) { 9 | return require('./cypress/plugins/index.js')(on, config) 10 | }, 11 | baseUrl: 'http://localhost:4000', 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /cypress/e2e/sample_app.cy.js: -------------------------------------------------------------------------------- 1 | const EMAIL_ADDRESS = 'myself@angular.dev'; 2 | 3 | describe('unauthenticated sample app', () => { 4 | beforeEach(() => { 5 | window.sessionStorage.clear(); 6 | }); 7 | 8 | it('loads', () => { 9 | cy.visit(''); 10 | }); 11 | 12 | it('renders home', () => { 13 | cy.visit('/#/home'); 14 | cy.get('button.btn').contains('Messages'); 15 | cy.get('button.btn').contains('Contacts'); 16 | cy.get('button.btn').contains('Preferences'); 17 | }); 18 | 19 | it('asks for authentication', () => { 20 | cy.visit('/#/home') 21 | .get('button.btn') 22 | .contains('Preferences') 23 | .click(); 24 | 25 | cy.contains('Log In'); 26 | cy.contains('Username'); 27 | cy.contains('Password'); 28 | 29 | expect(sessionStorage.getItem('appConfig')).to.equal(null); 30 | }); 31 | 32 | it('can authenticate', () => { 33 | expect(sessionStorage.getItem('appConfig')).to.equal(null); 34 | 35 | cy.visit('/#/prefs'); 36 | 37 | cy.contains('Log In'); 38 | cy.contains('Username'); 39 | cy.contains('Password'); 40 | 41 | cy.get('select').contains('myself').parent('select').select(EMAIL_ADDRESS); 42 | cy.get('button').contains('Log in').click(); 43 | 44 | cy.contains('Reset All Data') 45 | .then(() => { 46 | const appConfig = sessionStorage.getItem('appConfig'); 47 | expect(appConfig).not.to.equal(null); 48 | expect(appConfig.emailAddress).not.equal(EMAIL_ADDRESS); 49 | }); 50 | }); 51 | }); 52 | 53 | describe('authenticated sample app', () => { 54 | var _appConfig = null; 55 | beforeEach(() => { 56 | const applyAppConfig = () => { 57 | window.sessionStorage.clear(); 58 | window.sessionStorage.setItem('appConfig', _appConfig); 59 | }; 60 | 61 | if (!_appConfig) { 62 | cy.visit('/#/login'); 63 | cy.get('select').contains('myself').parent('select').select(EMAIL_ADDRESS); 64 | cy.get('button').contains('Log in').click(); 65 | cy.url().should('include', '#/home') 66 | .then(() => { 67 | const appConfig = sessionStorage.getItem('appConfig'); 68 | expect(appConfig).not.to.equal(null); 69 | expect(appConfig.emailAddress).not.equal(EMAIL_ADDRESS); 70 | _appConfig = appConfig; 71 | }) 72 | .then(applyAppConfig); 73 | } else { 74 | applyAppConfig(); 75 | } 76 | }); 77 | 78 | it('navigates to Preferences by url', () => { 79 | cy.visit('/#/prefs'); 80 | cy.contains('Reset All Data'); 81 | }); 82 | 83 | it('navigates to Contacts by url', () => { 84 | cy.visit('/#/contacts'); 85 | cy.contains('Select a contact'); 86 | }); 87 | 88 | it('navigates to Messages by url', () => { 89 | cy.visit('/#/mymessages'); 90 | cy.get('table').contains('Sender'); 91 | cy.get('table').contains('Subject'); 92 | }); 93 | 94 | it('can send a message', () => { 95 | cy.visit('/#/mymessages'); 96 | cy.url().should('include', '#/mymessages/inbox'); 97 | cy.contains('New Message').click(); 98 | cy.url().should('include', '#/mymessages/compose'); 99 | cy.get('input#to').type('somebody@somewhere.com'); 100 | cy.get('input#subject').type('Hello World'); 101 | cy.get('textarea#body').type('The quick brown fox jumps over the lazy dog'); 102 | cy.get('button').contains('Send').click(); 103 | 104 | cy.contains('Sender'); 105 | cy.get('li a').contains('sent').click(); 106 | cy.contains('Hello World'); 107 | cy.get('table').contains('Hello World'); 108 | cy.get('table').contains('somebody@somewhere.com'); 109 | }); 110 | 111 | it('can save a draft', () => { 112 | cy.visit('/#/mymessages'); 113 | cy.url().should('include', '#/mymessages/inbox'); 114 | cy.contains('New Message').click(); 115 | cy.get('input#to').type('somebody@somewhere.com'); 116 | cy.get('input#subject').type('Hello World'); 117 | cy.get('textarea#body').type('The quick brown fox jumps over the lazy dog'); 118 | cy.get('button').contains('Draft').click(); 119 | 120 | cy.contains('Sender'); 121 | cy.get('li a').contains('drafts').click(); 122 | cy.contains('Hello World'); 123 | cy.get('table').contains('Hello World'); 124 | cy.get('table').contains('somebody@somewhere.com'); 125 | }); 126 | 127 | it('prompts to save a message being composed', () => { 128 | cy.visit('/#/mymessages'); 129 | cy.url().should('include', '#/mymessages/inbox'); 130 | cy.contains('New Message').click(); 131 | cy.get('input#to').type('somebody@somewhere.com'); 132 | cy.get('button').contains('Cancel').click(); 133 | 134 | cy.get('.backdrop'); 135 | cy.contains('Navigate away'); 136 | cy.get('button').contains('No').click(); 137 | cy.get('.backdrop').should('not.exist'); 138 | cy.url().should('include', '#/mymessages/compose'); 139 | 140 | 141 | cy.get('button').contains('Cancel').click(); 142 | cy.get('.backdrop'); 143 | cy.contains('Navigate away'); 144 | cy.get('button').contains('Yes').click(); 145 | cy.get('.backdrop').should('not.exist'); 146 | 147 | cy.contains('Sender'); 148 | cy.contains('Subject'); 149 | cy.url().should('include', '#/mymessages/inbox'); 150 | }); 151 | 152 | it('navigates through folders', () => { 153 | cy.visit('/#/mymessages'); 154 | cy.url().should('include', '#/mymessages/inbox'); 155 | 156 | cy.contains('inbox').parent('li').should('have.class', 'selected'); 157 | cy.contains('Longer in style'); 158 | 159 | cy.contains('finance').click().parent('li').should('have.class', 'selected'); 160 | cy.contains('You look angerly'); 161 | cy.url().should('include', '#/mymessages/finance'); 162 | 163 | cy.contains('travel').click().parent('li').should('have.class', 'selected'); 164 | cy.contains('In areas of lush forest'); 165 | cy.url().should('include', '#/mymessages/travel'); 166 | 167 | cy.contains('personal').click().parent('li').should('have.class', 'selected'); 168 | cy.contains('Mother is not all'); 169 | cy.url().should('include', '#/mymessages/personal'); 170 | }); 171 | 172 | it('navigates through messages', () => { 173 | const selectMessage = (subject, guid) => { 174 | cy.contains(subject).click(); 175 | cy.url().should('contain', guid); 176 | cy.get('.message h4').contains(subject); 177 | }; 178 | 179 | cy.visit('/#/mymessages/finance'); 180 | cy.contains('finance').parent('li').should('have.class', 'selected'); 181 | 182 | selectMessage('You look angerly', '5648b50cf8ea6dfc7d1a40a8'); 183 | selectMessage('Historical change consequent', '5648b50c66b80016c9acc467'); 184 | selectMessage('The gracious Duncan', '5648b50d05f033d24fe5a1a2'); 185 | selectMessage('Rings, does not die', '5648b50c8e0e098cef934e04'); 186 | }); 187 | 188 | it('navigates through contacts', () => { 189 | cy.visit('/#/contacts'); 190 | 191 | const selectContact = (name, id) => { 192 | cy.contains(name).click(); 193 | cy.url().should('contain', id); 194 | cy.get('li').contains(name).should('have.class', 'selected'); 195 | cy.get('.contact h3').contains(name); 196 | }; 197 | 198 | selectContact('Rios Sears', 'rsears'); 199 | selectContact('Delia Hunter', 'dhunter'); 200 | selectContact('Underwood Owens', 'uowens'); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/e2e.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 | -------------------------------------------------------------------------------- /dependencies.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | dependencies: 3 | - type: js 4 | settings: 5 | commit_message_prefix: "chore(package): " 6 | related_pr_behavior: close 7 | github_labels: 8 | - dependencies 9 | manifest_updates: 10 | filters: 11 | - name: "typescript" 12 | versions: L.L.Y 13 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/0.13/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-remap-istanbul'), 12 | require('@angular-devkit/build-angular/plugins/karma') 13 | ], 14 | files: [ 15 | 16 | ], 17 | preprocessors: { 18 | 19 | }, 20 | mime: { 21 | 'text/x-typescript': ['ts','tsx'] 22 | }, 23 | remapIstanbulReporter: { 24 | dir: require('path').join(__dirname, 'coverage'), reports: { 25 | html: 'coverage', 26 | lcovonly: './coverage/coverage.lcov' 27 | } 28 | }, 29 | angularCli: { 30 | config: './angular-cli.json', 31 | environment: 'dev' 32 | }, 33 | reporters: config.angularCli && config.angularCli.codeCoverage 34 | ? ['progress', 'karma-remap-istanbul'] 35 | : ['progress'], 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: true, 40 | browsers: ['Chrome'], 41 | singleRun: false 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-app-angular", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "angular-cli": {}, 6 | "checkPeerDependencies": { 7 | "ignore": [ 8 | "ajv", 9 | "postcss", 10 | "terser" 11 | ] 12 | }, 13 | "scripts": { 14 | "ng": "ng", 15 | "start": "ng serve --configuration production", 16 | "build": "ng build --configuration production", 17 | "test": "npm run build && cypress-runner run --path=dist/browser", 18 | "test:open": "npm run build && cypress-runner open --path=dist/browser", 19 | "e2e": "npm run test", 20 | "gh-pages": "ng build --base-href=/sample-app-angular/ && shx rm -rf pages && shx mkdir pages && cd pages && git init && git remote add pages git@github.com:ui-router/sample-app-angular.git && git fetch pages && git checkout gh-pages && git rm -rf * && shx mv ../dist/browser/* . && git add . && git commit -m 'Update gh-pages' . && git push && cd .. && shx rm -rf pages" 21 | }, 22 | "private": true, 23 | "dependencies": { 24 | "@angular/common": "^19.0.5", 25 | "@angular/compiler": "^19.0.5", 26 | "@angular/core": "^19.0.5", 27 | "@angular/forms": "^19.0.5", 28 | "@angular/platform-browser": "^19.0.5", 29 | "@angular/platform-browser-dynamic": "^19.0.5", 30 | "@uirouter/angular": "^14.0.0", 31 | "@uirouter/core": "6.1.0", 32 | "@uirouter/rx": "1.0.0", 33 | "@uirouter/visualizer": "^7.2.1", 34 | "rxjs": "^7.4.0", 35 | "zone.js": "~0.15.0" 36 | }, 37 | "devDependencies": { 38 | "@angular-devkit/build-angular": "^19.0.6", 39 | "@angular/animations": "^19.0.5", 40 | "@angular/cli": "^19.0.6", 41 | "@angular/compiler-cli": "^19.0.5", 42 | "@types/jasmine": "~3.10.2", 43 | "@uirouter/cypress-runner": "^3.0.0", 44 | "tslint": "6.1.3", 45 | "typescript": "~5.6.3" 46 | }, 47 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 48 | } 49 | -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | /*global jasmine */ 5 | var SpecReporter = require('jasmine-spec-reporter'); 6 | 7 | exports.config = { 8 | allScriptsTimeout: 11000, 9 | specs: [ 10 | './e2e/**/*.e2e-spec.ts' 11 | ], 12 | capabilities: { 13 | 'browserName': 'chrome' 14 | }, 15 | directConnect: true, 16 | baseUrl: 'http://localhost:4200/', 17 | framework: 'jasmine', 18 | jasmineNodeOpts: { 19 | showColors: true, 20 | defaultTimeoutInterval: 30000, 21 | print: function() {} 22 | }, 23 | useAllAngular2AppRoots: true, 24 | beforeLaunch: function() { 25 | require('ts-node').register({ 26 | project: 'e2e' 27 | }); 28 | }, 29 | onPrepare: function() { 30 | jasmine.getEnv().addReporter(new SpecReporter()); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/README.md: -------------------------------------------------------------------------------- 1 | ## Contents 2 | 3 | ### The main app module bootstrap 4 | 5 | - *app.states*.js: Defines the top-level states such as home, welcome, and login 6 | 7 | ### Components for the Top-level states 8 | 9 | - *app.component*.js: A component which displays the header nav-bar for authenticated in users 10 | - *home.component*.js: A component that has links to the main submodules 11 | - *login.component*.js: A component for authenticating a guest user 12 | - *welcome.component*.js: A component which displays a welcome screen for guest users 13 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild, ViewContainerRef, OnInit } from '@angular/core'; 2 | import { DialogService } from './global/dialog.service'; 3 | import { StateService } from '@uirouter/core'; 4 | import { AuthService } from './global/auth.service'; 5 | import { AppConfigService } from './global/app-config.service'; 6 | 7 | /** 8 | * This is the main app component for an authenticated user. 9 | * 10 | * This component renders the outermost chrome 11 | * (application header and tabs, the compose and logout button) 12 | * It has a `ui-view` viewport for nested states to fill in. 13 | */ 14 | @Component({ 15 | selector: 'app-root', 16 | template: ` 17 |
18 | 43 | 44 | 45 | `, 46 | styles: [], 47 | standalone: false 48 | }) 49 | export class AppComponent implements OnInit { 50 | @ViewChild('dialogdiv', { read: ViewContainerRef, static: true }) dialogdiv; 51 | 52 | // data 53 | emailAddress; 54 | isAuthenticated; 55 | 56 | constructor(appConfig: AppConfigService, 57 | public authService: AuthService, 58 | public $state: StateService, 59 | private dialog: DialogService 60 | ) { 61 | this.emailAddress = appConfig.emailAddress; 62 | this.isAuthenticated = authService.isAuthenticated(); 63 | } 64 | 65 | ngOnInit() { 66 | this.dialog.vcRef = this.dialogdiv; 67 | } 68 | 69 | show() { 70 | this.dialog.confirm('foo'); 71 | } 72 | 73 | logout() { 74 | const { authService, $state } = this; 75 | authService.logout(); 76 | // Reload states after authentication change 77 | return $state.go('welcome', {}, { reload: true }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | 5 | import { MainComponent } from './main.component'; 6 | import { AppComponent } from './app.component'; 7 | import { WelcomeComponent } from './welcome.component'; 8 | import { LoginComponent } from './login.component'; 9 | import { HomeComponent } from './home.component'; 10 | import { UIRouterModule } from '@uirouter/angular'; 11 | import { APP_STATES } from './app.states'; 12 | import { GlobalModule } from './global/global.module'; 13 | import { routerConfigFn } from './router.config'; 14 | 15 | @NgModule({ 16 | declarations: [ 17 | MainComponent, 18 | AppComponent, 19 | WelcomeComponent, 20 | LoginComponent, 21 | HomeComponent 22 | ], 23 | imports: [ 24 | UIRouterModule.forRoot({ 25 | states: APP_STATES, 26 | useHash: true, 27 | initial: { state: 'home' }, 28 | config: routerConfigFn, 29 | }), 30 | GlobalModule, 31 | BrowserModule, 32 | FormsModule 33 | ], 34 | bootstrap: [MainComponent] 35 | }) 36 | export class AppModule { } 37 | -------------------------------------------------------------------------------- /src/app/app.states.ts: -------------------------------------------------------------------------------- 1 | import { AppComponent } from './app.component'; 2 | import { WelcomeComponent } from './welcome.component'; 3 | import { HomeComponent } from './home.component'; 4 | import { LoginComponent } from './login.component'; 5 | import { Transition } from '@uirouter/core'; 6 | 7 | /** 8 | * This is the parent state for the entire application. 9 | * 10 | * This state's primary purposes are: 11 | * 1) Shows the outermost chrome (including the navigation and logout for authenticated users) 12 | * 2) Provide a viewport (ui-view) for a substate to plug into 13 | */ 14 | export const appState = { 15 | name: 'app', 16 | redirectTo: 'welcome', 17 | component: AppComponent, 18 | }; 19 | 20 | /** 21 | * This is the 'welcome' state. It is the default state (as defined by app.js) if no other state 22 | * can be matched to the URL. 23 | */ 24 | export const welcomeState = { 25 | parent: 'app', 26 | name: 'welcome', 27 | url: '/welcome', 28 | component: WelcomeComponent, 29 | }; 30 | 31 | /** 32 | * This is a home screen for authenticated users. 33 | * 34 | * It shows giant buttons which activate their respective submodules: Messages, Contacts, Preferences 35 | */ 36 | export const homeState = { 37 | parent: 'app', 38 | name: 'home', 39 | url: '/home', 40 | component: HomeComponent, 41 | }; 42 | 43 | 44 | /** 45 | * This is the login state. It is activated when the user navigates to /login, or if a unauthenticated 46 | * user attempts to access a protected state (or substate) which requires authentication. (see routerhooks/requiresAuth.js) 47 | * 48 | * It shows a fake login dialog and prompts the user to authenticate. Once the user authenticates, it then 49 | * reactivates the state that the user originally came from. 50 | */ 51 | export const loginState = { 52 | parent: 'app', 53 | name: 'login', 54 | url: '/login', 55 | component: LoginComponent, 56 | resolve: [ 57 | { token: 'returnTo', deps: [Transition], resolveFn: returnTo }, 58 | ] 59 | }; 60 | 61 | /** 62 | * A resolve function for 'login' state which figures out what state to return to, after a successful login. 63 | * 64 | * If the user was initially redirected to login state (due to the requiresAuth redirect), then return the toState/params 65 | * they were redirected from. Otherwise, if they transitioned directly, return the fromState/params. Otherwise 66 | * return the main "home" state. 67 | */ 68 | export function returnTo ($transition$: Transition): any { 69 | if ($transition$.redirectedFrom() != null) { 70 | // The user was redirected to the login state (e.g., via the requiresAuth hook when trying to activate contacts) 71 | // Return to the original attempted target state (e.g., contacts) 72 | return $transition$.redirectedFrom().targetState(); 73 | } 74 | 75 | const $state = $transition$.router.stateService; 76 | 77 | // The user was not redirected to the login state; they directly activated the login state somehow. 78 | // Return them to the state they came from. 79 | if ($transition$.from().name !== '') { 80 | return $state.target($transition$.from(), $transition$.params('from')); 81 | } 82 | 83 | // If the fromState's name is empty, then this was the initial transition. Just return them to the home state 84 | return $state.target('home'); 85 | } 86 | 87 | // This future state is a placeholder for all the lazy loaded Contacts states 88 | // The Contacts NgModule isn't loaded until a Contacts link is activated 89 | export const contactsFutureState = { 90 | name: 'contacts.**', 91 | url: '/contacts', 92 | loadChildren: () => import('./contacts/contacts.module').then(m => m.ContactsModule) 93 | }; 94 | 95 | // This future state is a placeholder for the lazy loaded Prefs states 96 | export const prefsFutureState = { 97 | name: 'prefs.**', 98 | url: '/prefs', 99 | loadChildren: () => import('./prefs/prefs.module').then(m => m.PrefsModule) 100 | }; 101 | 102 | // This future state is a placeholder for the lazy loaded My Messages feature module 103 | export const mymessagesFutureState = { 104 | name: 'mymessages.**', 105 | url: '/mymessages', 106 | loadChildren: () => import('./mymessages/mymessages.module').then(m => m.MymessagesModule) 107 | }; 108 | 109 | export const APP_STATES = [ 110 | appState, 111 | welcomeState, 112 | homeState, 113 | loginState, 114 | contactsFutureState, 115 | prefsFutureState, 116 | mymessagesFutureState, 117 | ]; 118 | -------------------------------------------------------------------------------- /src/app/contacts/README.md: -------------------------------------------------------------------------------- 1 | ### The NgModule 2 | - *contacts.module*.ts: A Contacts feature module (NgModule) 3 | 4 | ## NgModule Contents 5 | 6 | ### The Contacts submodule states 7 | 8 | - *contacts.states*.ts: Defines the Contacts ui-router states 9 | 10 | ### The Contacts submodule components 11 | 12 | - *contact-detail.component*.ts: A component which renders a read only view of the details for a single contact. 13 | - *contact-list.component*.ts: A component which renders a list of contacts 14 | - *contacts.component*.ts: A component which renders the contacts submodule. 15 | - *contact.component*.ts: A component which renders details and controls for a single contact 16 | - *edit-contact.component*.ts: A component which edits a single contact. 17 | 18 | ### The Contacts fake REST API 19 | 20 | - *contacts.service*.ts: A fake REST API which can GET/POST contact(s) 21 | -------------------------------------------------------------------------------- /src/app/contacts/contact-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * This component renders a read only view of the details for a single contact. 5 | */ 6 | @Component({ 7 | selector: 'app-contact-detail', 8 | template: ` 9 |
10 |
11 |

{{contact.name.first}} {{contact.name.last}}

12 |
{{contact.company}}
13 |
{{contact.age}}
14 |
{{contact.phone}}
15 |
{{contact.email}}
16 |
17 | 18 |
{{contact.address.street}}
19 | {{contact.address.city}}, {{contact.address.state}} {{contact.address.zip}} 20 |
21 |
22 |
23 | 24 |
25 | 26 |
27 |
28 | `, 29 | styles: [], 30 | standalone: false 31 | }) 32 | export class ContactDetailComponent { 33 | @Input() contact; 34 | constructor() { } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/contacts/contact-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * This component renders a list of contacts. 5 | * 6 | * At the top is a "new contact" button. 7 | * Each list item is a clickable link to the `contacts.contact` details substate 8 | */ 9 | @Component({ 10 | selector: 'app-contact-list', 11 | template: ` 12 | 33 | `, 34 | styles: [], 35 | standalone: false 36 | }) 37 | export class ContactListComponent { 38 | @Input() contacts; 39 | constructor() { } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/contacts/contact.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * This component renders details for a single contact 5 | * 6 | * A button messages the contact by linking to `mymessages.compose` state passing the email as a state parameter. 7 | * Another button edits the contact by linking to `contacts.contact.edit` state. 8 | */ 9 | @Component({ 10 | selector: 'app-contact', 11 | template: ` 12 |
13 | 14 | 15 | 17 | 20 | 21 | 22 | 25 | 26 |
27 | `, 28 | styles: [], 29 | standalone: false 30 | }) 31 | export class ContactComponent { 32 | @Input() contact; 33 | constructor() { } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/contacts/contacts-data.service.ts: -------------------------------------------------------------------------------- 1 | import { AppConfigService } from '../global/app-config.service'; 2 | import { Injectable } from '@angular/core'; 3 | import { SessionStorage } from '../util/sessionStorage'; 4 | 5 | export interface Contact { 6 | tags: any[]; 7 | address: { 8 | zip: number; 9 | state: string; 10 | city: string; 11 | street: string; 12 | }; 13 | phone: string; 14 | email: string; 15 | company: string; 16 | age: number; 17 | picture: string; 18 | _id: string; 19 | name: { 20 | last: string; 21 | first: string 22 | }; 23 | } 24 | 25 | /** A fake Contacts REST client API */ 26 | @Injectable() 27 | export class ContactsDataService extends SessionStorage { 28 | constructor(appConfig: AppConfigService) { 29 | // http://beta.json-generator.com/api/json/get/V1g6UwwGx 30 | super('contacts', 'assets/contacts.json', appConfig); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/contacts/contacts.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * This component renders the contacts submodule. 5 | * 6 | * On the left is the list of contacts. 7 | * On the right is the ui-view viewport where contact details appear. 8 | */ 9 | @Component({ 10 | selector: 'app-contacts', 11 | template: ` 12 |
13 | 14 | 15 | 16 | 17 | 18 |

Select a contact

19 |
20 |
21 | `, 22 | styles: [], 23 | standalone: false 24 | }) 25 | export class ContactsComponent { 26 | @Input() contacts; 27 | constructor() { } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/contacts/contacts.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ContactComponent } from './contact.component'; 4 | import { ContactDetailComponent } from './contact-detail.component'; 5 | import { ContactListComponent } from './contact-list.component'; 6 | import { ContactsComponent } from './contacts.component'; 7 | import { EditContactComponent } from './edit-contact.component'; 8 | import { CONTACTS_STATES } from './contacts.states'; 9 | import { UIRouterModule } from '@uirouter/angular'; 10 | import { FormsModule } from '@angular/forms'; 11 | import { ContactsDataService } from './contacts-data.service'; 12 | 13 | @NgModule({ 14 | imports: [ 15 | UIRouterModule.forChild({ states: CONTACTS_STATES }), 16 | FormsModule, 17 | CommonModule 18 | ], 19 | declarations: [ 20 | ContactComponent, 21 | ContactDetailComponent, 22 | ContactListComponent, 23 | ContactsComponent, 24 | EditContactComponent 25 | ], 26 | providers: [ 27 | ContactsDataService 28 | ], 29 | }) 30 | export class ContactsModule { 31 | } 32 | -------------------------------------------------------------------------------- /src/app/contacts/contacts.states.ts: -------------------------------------------------------------------------------- 1 | import {Ng2StateDeclaration} from '@uirouter/angular'; 2 | 3 | import {ContactComponent} from './contact.component'; 4 | import {ContactsComponent} from './contacts.component'; 5 | import {EditContactComponent} from './edit-contact.component'; 6 | import { ContactsDataService } from './contacts-data.service'; 7 | import { Transition } from '@uirouter/core'; 8 | 9 | 10 | export function getAllContacts(contactSvc) { 11 | return contactSvc.all(); 12 | } 13 | 14 | /** 15 | * This state displays the contact list. 16 | * It also provides a nested ui-view (viewport) for child states to fill in. 17 | * 18 | * The contacts are fetched using a resolve. 19 | */ 20 | export const contactsState: Ng2StateDeclaration = { 21 | parent: 'app', // declares that 'contacts' is a child of 'app' 22 | name: 'contacts', 23 | url: '/contacts', 24 | component: ContactsComponent, 25 | resolve: [ 26 | // Resolve all the contacts. The resolved contacts are injected into the controller. 27 | { token: 'contacts', deps: [ContactsDataService], resolveFn: getAllContacts }, 28 | ], 29 | data: { requiresAuth: true }, 30 | }; 31 | 32 | 33 | export function getOneContact (contactSvc, $transition$) { 34 | return contactSvc.get($transition$.params().contactId); 35 | } 36 | 37 | /** 38 | * This state displays a single contact. 39 | * The contact to display is fetched using a resolve, based on the `contactId` parameter. 40 | */ 41 | export const viewContactState: Ng2StateDeclaration = { 42 | name: 'contacts.contact', 43 | url: '/:contactId', 44 | component: ContactComponent, 45 | resolve: [ 46 | // Resolve the contact, based on the contactId parameter value. 47 | // The resolved contact is provided to the contactComponent's contact binding 48 | { token: 'contact', deps: [ContactsDataService, Transition], resolveFn: getOneContact }, 49 | ], 50 | }; 51 | 52 | 53 | /** 54 | * This state allows a user to edit a contact 55 | * 56 | * The contact data to edit is injected from the parent state's resolve. 57 | * 58 | * This state uses view targeting to replace the parent ui-view (which would normally be filled 59 | * by 'contacts.contact') with the edit contact template/controller 60 | */ 61 | export const editContactState: Ng2StateDeclaration = { 62 | name: 'contacts.contact.edit', 63 | url: '/edit', 64 | views: { 65 | // Relatively target the grand-parent-state's $default (unnamed) ui-view 66 | // This could also have been written using ui-view@state addressing: $default@contacts 67 | // Or, this could also have been written using absolute ui-view addressing: !$default.$default.$default 68 | '^.^.$default': { 69 | component: EditContactComponent, 70 | bindings: { pristineContact: 'contact' }, 71 | } 72 | }, 73 | }; 74 | 75 | 76 | export function getBlankContact() { 77 | return { name: {}, address: {} }; 78 | } 79 | 80 | /** 81 | * This state allows a user to create a new contact 82 | * 83 | * The contact data to edit is injected into the component from the parent state's resolve. 84 | */ 85 | export const newContactState: Ng2StateDeclaration = { 86 | name: 'contacts.new', 87 | url: '/new', 88 | component: EditContactComponent, 89 | resolve: [ 90 | { token: 'pristineContact', deps: [], resolveFn: getBlankContact } 91 | ], 92 | }; 93 | 94 | export const CONTACTS_STATES = [ 95 | contactsState, 96 | viewContactState, 97 | editContactState, 98 | newContactState, 99 | ]; 100 | -------------------------------------------------------------------------------- /src/app/contacts/edit-contact.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, Inject, OnDestroy } from '@angular/core'; 2 | import { StateService, TransitionService, equals, StateDeclaration } from '@uirouter/core'; 3 | import { DialogService } from '../global/dialog.service'; 4 | import { ContactsDataService } from './contacts-data.service'; 5 | import { copy } from '../util/util'; 6 | 7 | /** 8 | * The EditContact component 9 | * 10 | * This component is used by both `contacts.contact.edit` and `contacts.new` states. 11 | * 12 | * The component makes a copy of the contqct data for editing. 13 | * The new copy and original (pristine) copy are used to determine if the contact is "dirty" or not. 14 | * If the user navigates to some other state while the contact is "dirty", the `uiCanExit` component 15 | * hook asks the user to confirm navigation away, losing any edits. 16 | * 17 | * The Delete Contact button is wired to the `remove` method, which: 18 | * - asks for confirmation from the user 19 | * - deletes the resource from REST API 20 | * - navigates back to the contacts grandparent state using relative addressing `^.^` 21 | * the `reload: true` option re-fetches the contacts list from the server 22 | * 23 | * The Save Contact button is wired to the `save` method which: 24 | * - saves the REST resource (PUT or POST, depending) 25 | * - navigates back to the parent state using relative addressing `^`. 26 | * when editing an existing contact, this returns to the `contacts.contact` "view contact" state 27 | * when creating a new contact, this returns to the `contacts` list. 28 | * the `reload: true` option re-fetches the contacts resolve data from the server 29 | */ 30 | @Component({ 31 | selector: 'app-edit-contact', 32 | template: ` 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 |
49 | 50 |
51 | 52 | 53 | 54 | 55 |
56 |
57 | `, 58 | styles: [], 59 | standalone: false 60 | }) 61 | export class EditContactComponent implements OnInit { 62 | @Input() pristineContact; 63 | contact; 64 | canExit: boolean; 65 | 66 | constructor(public $state: StateService, 67 | public dialogService: DialogService, 68 | public contactsService: ContactsDataService, 69 | // The state that is routing to the component, which could 70 | // be either contacts.new or contacts.contact.edit 71 | @Inject('$state$') public $state$: StateDeclaration, 72 | public transitionService: TransitionService) { 73 | } 74 | 75 | ngOnInit() { 76 | // Make an editable copy of the pristineContact 77 | this.contact = copy(this.pristineContact); 78 | } 79 | 80 | uiCanExit() { 81 | if (this.canExit || equals(this.contact, this.pristineContact)) { 82 | return true; 83 | } 84 | 85 | const message = 'You have unsaved changes to this contact.'; 86 | const question = 'Navigate away and lose changes?'; 87 | return this.dialogService.confirm(message, question); 88 | } 89 | 90 | /** Ask for confirmation, then delete the contact, then go to the grandparent state ('contacts') */ 91 | remove(contact) { 92 | this.dialogService.confirm(`Delete contact: ${contact.name.first} ${contact.name.last}`) 93 | .then(() => this.contactsService.remove(contact)) 94 | .then(() => this.canExit = true) 95 | .then(() => this.$state.go('^.^', null, { reload: true })); 96 | } 97 | 98 | /** Save the contact, then go to the parent state (either 'contacts' or 'contacts.contact') */ 99 | save(contact) { 100 | this.contactsService.save(contact) 101 | .then(() => this.canExit = true) 102 | .then(() => this.$state.go('^', null, { reload: true })); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/app/global/README.md: -------------------------------------------------------------------------------- 1 | ### The NgModule 2 | - *global.module*.ts: An Angular Module for global app code that doesn't make sense in a feature module 3 | 4 | ## NgModule Contents 5 | 6 | ### App Services 7 | 8 | - *app-config*.service.ts: Stores and retrieves the user's application preferences 9 | - *auth.service*.ts: Simulates an authentication service 10 | - *dialog.service*.ts: Provides a dialog confirmation (are you sure? yes/no) service 11 | 12 | ### Components 13 | 14 | - *dialog.component*.ts: A dialog component used by the dialog service 15 | 16 | ### Router Hooks 17 | 18 | - *auth.hook*.ts: A transition hook which allows a state to declare that it requires an authenticated user 19 | -------------------------------------------------------------------------------- /src/app/global/app-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject } from 'rxjs'; 3 | 4 | export interface SortOrder { 5 | sortBy: string; 6 | order: number; 7 | } 8 | 9 | /** 10 | * This service stores and retrieves user preferences in session storage 11 | */ 12 | @Injectable() 13 | export class AppConfigService { 14 | private _sort = '+date'; 15 | sort$ = new BehaviorSubject(this.parseSort(this.sort)); 16 | 17 | get sort() { return this._sort; } 18 | set sort(val: string) { 19 | this._sort = val; 20 | this.sort$.next(this.parseSort(val)); 21 | } 22 | 23 | emailAddress: string = undefined; 24 | restDelay = 100; 25 | 26 | constructor() { 27 | this.load(); 28 | } 29 | 30 | toObject() { 31 | return { 32 | sort: this.sort, 33 | emailAddress: this.emailAddress, 34 | restDelay: this.restDelay, 35 | }; 36 | } 37 | 38 | parseSort(sort: string): SortOrder { 39 | const defaultSort = '+date'; 40 | const sortOrder = sort || defaultSort; 41 | const pattern = /^([+-])(.*)$/; 42 | const match = pattern.exec(sortOrder) || pattern.exec(defaultSort); 43 | const [__, order, sortBy] = match; 44 | 45 | return { sortBy, order: (order === '-' ? -1 : 1) }; 46 | } 47 | 48 | load() { 49 | try { 50 | const data = JSON.parse(sessionStorage.getItem('appConfig')); 51 | return Object.assign(this, data); 52 | } catch (Error) { } 53 | 54 | return this; 55 | } 56 | 57 | save() { 58 | const string = JSON.stringify(this.toObject()); 59 | sessionStorage.setItem('appConfig', string); 60 | } 61 | 62 | } 63 | 64 | -------------------------------------------------------------------------------- /src/app/global/auth.hook.ts: -------------------------------------------------------------------------------- 1 | import { TransitionService } from '@uirouter/core'; 2 | import { AuthService } from './auth.service'; 3 | 4 | /** 5 | * This file contains a Transition Hook which protects a 6 | * route that requires authentication. 7 | * 8 | * This hook redirects to /login when both: 9 | * - The user is not authenticated 10 | * - The user is navigating to a state that requires authentication 11 | */ 12 | export function requiresAuthHook(transitionService: TransitionService) { 13 | // Matches if the destination state's data property has a truthy 'requiresAuth' property 14 | const requiresAuthCriteria = { 15 | to: (state) => state.data && state.data.requiresAuth 16 | }; 17 | 18 | // Function that returns a redirect for the current transition to the login state 19 | // if the user is not currently authenticated (according to the AuthService) 20 | 21 | const redirectToLogin = (transition) => { 22 | const authService: AuthService = transition.injector().get(AuthService); 23 | const $state = transition.router.stateService; 24 | if (!authService.isAuthenticated()) { 25 | return $state.target('login', undefined, { location: false }); 26 | } 27 | }; 28 | 29 | // Register the "requires auth" hook with the TransitionsService 30 | transitionService.onBefore(requiresAuthCriteria, redirectToLogin, {priority: 10}); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/global/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AppConfigService } from './app-config.service'; 3 | import { wait } from '../util/util'; 4 | 5 | /** 6 | * This service emulates an Authentication Service. 7 | */ 8 | @Injectable() 9 | export class AuthService { 10 | // data 11 | usernames: string[] = ['myself@angular.dev', 'devgal@angular.dev', 'devguy@angular.dev']; 12 | 13 | constructor(public appConfig: AppConfigService) { } 14 | 15 | /** 16 | * Returns true if the user is currently authenticated, else false 17 | */ 18 | isAuthenticated() { 19 | return !!this.appConfig.emailAddress; 20 | } 21 | 22 | /** 23 | * Fake authentication function that returns a promise that is either resolved or rejected. 24 | * 25 | * Given a username and password, checks that the username matches one of the known 26 | * usernames (this.usernames), and that the password matches 'password'. 27 | * 28 | * Delays 800ms to simulate an async REST API delay. 29 | */ 30 | authenticate(username, password) { 31 | const appConfig = this.appConfig; 32 | 33 | // checks if the username is one of the known usernames, and the password is 'password' 34 | const checkCredentials = () => new Promise((resolve, reject) => { 35 | const validUsername = this.usernames.indexOf(username) !== -1; 36 | const validPassword = password === 'password'; 37 | 38 | return (validUsername && validPassword) ? resolve(username) : reject('Invalid username or password'); 39 | }); 40 | 41 | return wait(800) 42 | .then(checkCredentials) 43 | .then((authenticatedUser: string) => { 44 | appConfig.emailAddress = authenticatedUser; 45 | appConfig.save(); 46 | }); 47 | } 48 | 49 | /** Logs the current user out */ 50 | logout() { 51 | this.appConfig.emailAddress = undefined; 52 | this.appConfig.save(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/global/dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, HostBinding } from '@angular/core'; 2 | import { wait } from '../util/util'; 3 | 4 | @Component({ 5 | selector: 'app-dialog', 6 | template: ` 7 |
8 |
9 |
10 |

{{message}}

11 |
{{details}}
12 | 13 |
14 | 15 | 16 |
17 |
18 |
19 | `, 20 | styles: [], 21 | standalone: false 22 | }) 23 | export class DialogComponent implements OnInit { 24 | @HostBinding('class.dialog') dialog = true; 25 | @HostBinding('class.active') visible: boolean; 26 | 27 | message: string; 28 | details: string; 29 | yesMsg: string; 30 | noMsg: string; 31 | 32 | promise: Promise; 33 | 34 | constructor() { 35 | this.promise = new Promise((resolve, reject) => { 36 | this.yes = () => { 37 | this.visible = false; 38 | wait(300).then(resolve); 39 | }; 40 | 41 | this.no = () => { 42 | this.visible = false; 43 | wait(300).then(reject); 44 | }; 45 | }); 46 | } 47 | 48 | yes() {} 49 | no() {} 50 | 51 | ngOnInit() { 52 | wait(50).then(() => this.visible = true); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/global/dialog.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ViewContainerRef, ComponentFactoryResolver, Injector, ComponentFactory } from '@angular/core'; 2 | import { DialogComponent } from './dialog.component'; 3 | 4 | @Injectable() 5 | export class DialogService { 6 | vcRef: ViewContainerRef; 7 | private factory: ComponentFactory; 8 | 9 | constructor(resolver: ComponentFactoryResolver) { 10 | this.factory = resolver.resolveComponentFactory(DialogComponent); 11 | } 12 | 13 | confirm(message, details = 'Are you sure?', yesMsg = 'Yes', noMsg = 'No') { 14 | const componentRef = this.vcRef.createComponent(this.factory); 15 | const component = componentRef.instance; 16 | 17 | component.message = message; 18 | component.details = details; 19 | component.yesMsg = yesMsg; 20 | component.noMsg = noMsg; 21 | 22 | const destroy = () => componentRef.destroy(); 23 | component.promise.then(destroy, destroy); 24 | return component.promise; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/global/global.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { AppConfigService } from './app-config.service'; 4 | import { AuthService } from './auth.service'; 5 | import { DialogComponent } from './dialog.component'; 6 | import { DialogService } from './dialog.service'; 7 | 8 | @NgModule({ 9 | imports: [ 10 | CommonModule 11 | ], 12 | providers: [ 13 | AppConfigService, 14 | AuthService, 15 | DialogService, 16 | ], 17 | declarations: [DialogComponent] 18 | }) 19 | export class GlobalModule { } 20 | -------------------------------------------------------------------------------- /src/app/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | // This is a home component for authenticated users. 3 | // It shows giant buttons which activate their respective submodules: Messages, Contacts, Preferences 4 | @Component({ 5 | selector: 'app-home', 6 | template: ` 7 |
8 | 12 | 13 | 17 | 18 | 22 |
23 | `, 24 | styles: [], 25 | standalone: false 26 | }) 27 | export class HomeComponent { 28 | constructor() { } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | import { TargetState, StateService } from '@uirouter/core'; 3 | import { AuthService } from './global/auth.service'; 4 | import { AppConfigService } from './global/app-config.service'; 5 | 6 | /** 7 | * This component renders a faux authentication UI 8 | * 9 | * It prompts for the username/password (and gives hints with bouncy arrows) 10 | * It shows errors if the authentication failed for any reason. 11 | */ 12 | @Component({ 13 | selector: 'app-login', 14 | template: ` 15 |
16 |
17 |

Log In

18 |

19 | (This login screen is for demonstration only... 20 | just pick a username, enter 'password' and click "Log in")

21 |
22 | 23 |
24 | 25 | 28 | 29 | Choose 31 |
32 |
33 | 34 |
35 | 36 | 37 | 39 | Enter 'password' here 40 | 41 |
42 | 43 |
{{ errorMessage }}
44 | 45 |
46 |
47 | 50 | Click Me! 52 |
53 |
54 |
55 | `, 56 | styles: [], 57 | standalone: false 58 | }) 59 | export class LoginComponent { 60 | @Input() returnTo: TargetState; 61 | 62 | usernames: string[]; 63 | credentials = { username: null, password: null }; 64 | authenticating: boolean; 65 | errorMessage: string; 66 | 67 | constructor(appConfig: AppConfigService, 68 | private authService: AuthService, 69 | private $state: StateService 70 | ) { 71 | this.usernames = authService.usernames; 72 | 73 | this.credentials = { 74 | username: appConfig.emailAddress, 75 | password: 'password' 76 | }; 77 | } 78 | 79 | /** 80 | * The controller for the `login` component 81 | * 82 | * The `login` method validates the credentials. 83 | * Then it sends the user back to the `returnTo` state, which is provided as a resolve data. 84 | */ 85 | login(credentials) { 86 | this.authenticating = true; 87 | 88 | const returnToOriginalState = () => { 89 | const state = this.returnTo.state(); 90 | const params = this.returnTo.params(); 91 | const options = Object.assign({}, this.returnTo.options(), { reload: true }); 92 | this.$state.go(state, params, options); 93 | }; 94 | 95 | const showError = (errorMessage) => 96 | this.errorMessage = errorMessage; 97 | 98 | const stop = () => this.authenticating = false; 99 | this.authService.authenticate(credentials.username, credentials.password) 100 | .then(returnToOriginalState) 101 | .catch(showError) 102 | .then(stop, stop); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/app/main.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-main', 5 | template: `Loading...`, 6 | standalone: false 7 | }) 8 | export class MainComponent {} 9 | -------------------------------------------------------------------------------- /src/app/mymessages/compose.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { Transition, StateService, equals, TransitionService } from '@uirouter/core'; 3 | import { DialogService } from '../global/dialog.service'; 4 | import { AppConfigService } from '../global/app-config.service'; 5 | import { MessagesDataService } from './messages-data.service'; 6 | import { copy } from '../util/util'; 7 | 8 | /** 9 | * This component composes a message 10 | * 11 | * The message might be new, a saved draft, or a reply/forward. 12 | * A Cancel button discards the new message and returns to the previous state. 13 | * A Save As Draft button saves the message to the "drafts" folder. 14 | * A Send button sends the message 15 | */ 16 | @Component({ 17 | selector: 'app-compose', 18 | template: ` 19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 | 27 | 28 |
29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 | `, 37 | styles: [], 38 | standalone: false 39 | }) 40 | export class ComposeComponent implements OnInit { 41 | // data 42 | pristineMessage; 43 | message; 44 | canExit: boolean; 45 | 46 | constructor(public stateService: StateService, 47 | public transitionService: TransitionService, 48 | public DialogService: DialogService, 49 | public appConfig: AppConfigService, 50 | public messagesService: MessagesDataService, 51 | public transition: Transition, 52 | ) { } 53 | 54 | /** 55 | * Create our message's model using the current user's email address as 'message.from' 56 | * Then extend it with all the properties from (non-url) state parameter 'message'. 57 | * Keep two copies: the editable one and the original one. 58 | * These copies are used to check if the message is dirty. 59 | */ 60 | ngOnInit() { 61 | const messageParam = this.transition.params().message; 62 | this.pristineMessage = Object.assign({from: this.appConfig.emailAddress}, messageParam); 63 | this.message = copy(this.pristineMessage); 64 | } 65 | 66 | /** 67 | * Checks if the edited copy and the pristine copy are identical when the state is changing. 68 | * If they are not identical, the allows the user to confirm navigating away without saving. 69 | */ 70 | uiCanExit() { 71 | if (this.canExit || equals(this.pristineMessage, this.message)) { 72 | return true; 73 | } 74 | 75 | const message = 'You have not saved this message.'; 76 | const question = 'Navigate away and lose changes?'; 77 | return this.DialogService.confirm(message, question, 'Yes', 'No'); 78 | } 79 | 80 | /** 81 | * Navigates back to the previous state. 82 | * 83 | * - Checks the $transition$ which activated this controller for a 'from state' that isn't the implicit root state. 84 | * - If there is no previous state (because the user deep-linked in, etc), then go to 'mymessages.messagelist' 85 | */ 86 | gotoPreviousState() { 87 | const transition = this.transition; 88 | const hasPrevious = !!transition.from().name; 89 | const state = hasPrevious ? transition.from() : 'mymessages.messagelist'; 90 | const params = hasPrevious ? transition.params('from') : {}; 91 | this.stateService.go(state, params); 92 | }; 93 | 94 | /** "Send" the message (save to the 'sent' folder), and then go to the previous state */ 95 | send(message) { 96 | this.messagesService.save(Object.assign(message, {date: new Date(), read: true, folder: 'sent'})) 97 | .then(() => this.canExit = true) 98 | .then(() => this.gotoPreviousState()); 99 | }; 100 | 101 | /** Save the message to the 'drafts' folder, and then go to the previous state */ 102 | save(message) { 103 | this.messagesService.save(Object.assign(message, {date: new Date(), read: true, folder: 'drafts'})) 104 | .then(() => this.canExit = true) 105 | .then(() => this.gotoPreviousState()); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/app/mymessages/folder-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * Renders a list of folders 5 | */ 6 | @Component({ 7 | selector: 'app-folder-list', 8 | template: ` 9 | 10 |
11 | 21 |
22 | `, 23 | styles: [], 24 | standalone: false 25 | }) 26 | export class FolderListComponent { 27 | @Input() folders: any[]; 28 | constructor() { } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/mymessages/folders-data.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { SessionStorage } from '../util/sessionStorage'; 3 | import { AppConfigService } from '../global/app-config.service'; 4 | import { Folder } from './interface'; 5 | 6 | /** A fake REST client API for Folders resources */ 7 | @Injectable() 8 | export class FoldersDataService extends SessionStorage { 9 | constructor(appConfig: AppConfigService) { 10 | super('folders', 'assets/folders.json', appConfig); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/mymessages/format-message.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'formatMessage', 5 | standalone: false 6 | }) 7 | export class FormatMessagePipe implements PipeTransform { 8 | transform(value: string, args?: any): string { 9 | return value.split(/\n/).map(p => `

${p}

`).join('\n'); 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/app/mymessages/interface.ts: -------------------------------------------------------------------------------- 1 | export interface Folder { 2 | _id: string; 3 | columns: string[]; 4 | actions: string[]; 5 | } 6 | 7 | export interface Message { 8 | read: boolean; 9 | folder: string; 10 | body: string; 11 | subject: string; 12 | from: string; 13 | to: string; 14 | date: string; 15 | senderName: { 16 | last: string; 17 | first: string 18 | }; 19 | corpus: string; 20 | _id: string; 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/app/mymessages/message-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { Message } from './interface'; 4 | 5 | /** 6 | * This component renders a list of messages using the `messageTable` component 7 | */ 8 | @Component({ 9 | selector: 'app-message-list', 10 | template: ` 11 |
12 | 13 |
14 | `, 15 | styles: [], 16 | standalone: false 17 | }) 18 | export class MessageListComponent { 19 | @Input() folder; 20 | @Input() messages$: Observable; 21 | 22 | constructor() { } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/mymessages/message-table.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * A component that displays a folder of messages as a table 5 | * 6 | * If a row is clicked, the details of the message is shown using a relative ui-sref to `.message`. 7 | * 8 | * ui-sref-active is used to highlight the selected row. 9 | * 10 | * Shows/hides specific columns based on the `columns` input binding. 11 | */ 12 | @Component({ 13 | selector: 'app-message-table', 14 | template: ` 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
{{ message.from }}{{ message.to }}{{ message.subject }}{{ message.date | date: "yyyy-MM-dd" }}
38 | `, 39 | styles: [], 40 | standalone: false 41 | }) 42 | export class MessageTableComponent { 43 | @Input() columns: any[]; 44 | @Input() messages: any[]; 45 | 46 | constructor() { } 47 | 48 | colVisible(name) { 49 | return this.columns.indexOf(name) !== -1; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/mymessages/message.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input } from '@angular/core'; 2 | import { setProp } from '../util/util'; 3 | import { DialogService } from '../global/dialog.service'; 4 | import { MessagesDataService } from './messages-data.service'; 5 | import { StateService } from '@uirouter/core'; 6 | import { Subscription , Observable } from 'rxjs'; 7 | import { Folder, Message } from './interface'; 8 | 9 | /** 10 | * This component renders a single message 11 | * 12 | * Buttons perform actions related to the message. 13 | * Buttons are shown/hidden based on the folder's context. 14 | * For instance, a "draft" message can be edited, but can't be replied to. 15 | */ 16 | @Component({ 17 | selector: 'app-message', 18 | template: ` 19 |
20 | 21 |
22 |
23 |

{{message.subject}}

24 |
{{message.from}} {{message.to}}
25 |
26 | 27 |
28 |
{{message.date | date: 'longDate'}} {{message.date | date: 'mediumTime'}}
29 |
30 | 33 | 34 | 37 | 38 | 41 | 42 | 45 |
46 |
47 |
48 | 49 | 50 |
51 |
52 | `, 53 | styles: [], 54 | standalone: false 55 | }) 56 | export class MessageComponent implements OnInit { 57 | @Input() folder: Folder; 58 | @Input() message: Message; 59 | 60 | // What message should be activated if this message is deleted 61 | @Input() proximalMessage$: Observable; 62 | private proximalMessageSub: Subscription; 63 | proximalMessage: Message; 64 | 65 | // data 66 | actions; 67 | 68 | constructor(public stateService: StateService, 69 | public dialog: DialogService, 70 | public messagesService: MessagesDataService 71 | ) { } 72 | 73 | /** 74 | * When the user views a message, mark it as read and save (PUT) the resource. 75 | * 76 | * Apply the available actions for the message, depending on the folder the message belongs to. 77 | */ 78 | ngOnInit() { 79 | this.message.read = true; 80 | this.messagesService.put(this.message); 81 | 82 | this.actions = this.folder.actions.reduce((obj, action) => setProp(obj, action, true), {}); 83 | this.proximalMessageSub = this.proximalMessage$.subscribe(message => this.proximalMessage = message); 84 | } 85 | 86 | /** 87 | * Compose a new message as a reply to this one 88 | */ 89 | reply(message) { 90 | const replyMsg = makeResponseMsg('Re: ', message); 91 | this.stateService.go('mymessages.compose', { message: replyMsg }); 92 | }; 93 | 94 | /** 95 | * Compose a new message as a forward of this one. 96 | */ 97 | forward(message) { 98 | const fwdMsg = makeResponseMsg('Fwd: ', message); 99 | delete fwdMsg.to; 100 | this.stateService.go('mymessages.compose', { message: fwdMsg }); 101 | }; 102 | 103 | /** 104 | * Continue composing this (draft) message 105 | */ 106 | editDraft(message) { 107 | this.stateService.go('mymessages.compose', { message: message }); 108 | }; 109 | 110 | /** 111 | * Delete this message. 112 | * 113 | * - confirm deletion 114 | * - delete the message 115 | * - determine which message should be active 116 | * - show that message 117 | */ 118 | remove(message) { 119 | const nextMessageId = this.proximalMessage && this.proximalMessage._id; 120 | const nextState = nextMessageId ? 'mymessages.messagelist.message' : 'mymessages.messagelist'; 121 | const params = { messageId: nextMessageId }; 122 | 123 | this.dialog.confirm('Delete?', undefined) 124 | .then(() => this.messagesService.remove(message)) 125 | .then(() => this.stateService.go(nextState, params, { reload: 'mymessages.messagelist' })); 126 | }; 127 | } 128 | 129 | 130 | 131 | /** Helper function to prefix a message with "fwd: " or "re: " */ 132 | const prefixSubject = (prefix, message) => prefix + message.subject; 133 | /** Helper function which quotes an email message */ 134 | const quoteMessage = (message) => ` 135 | 136 | 137 | 138 | --------------------------------------- 139 | Original message: 140 | From: ${message.from} 141 | Date: ${message.date} 142 | Subject: ${message.subject} 143 | 144 | ${message.body}`; 145 | 146 | /** Helper function to make a response message object */ 147 | function makeResponseMsg(subjectPrefix, origMsg) { 148 | return { 149 | from: origMsg.to, 150 | to: origMsg.from, 151 | subject: prefixSubject(subjectPrefix, origMsg), 152 | body: quoteMessage(origMsg) 153 | }; 154 | } 155 | 156 | -------------------------------------------------------------------------------- /src/app/mymessages/messages-data.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { SessionStorage } from '../util/sessionStorage'; 3 | import { AppConfigService, SortOrder } from '../global/app-config.service'; 4 | import { Folder, Message } from './interface'; 5 | 6 | /** A fake REST client API for Messages resources */ 7 | @Injectable() 8 | export class MessagesDataService extends SessionStorage { 9 | static sortedMessages(messages: Message[], sortOrder: SortOrder): Message[] { 10 | const getField = (message: Message) => 11 | message[sortOrder.sortBy].toString(); 12 | 13 | return messages.slice().sort((a, b) => 14 | getField(a).localeCompare(getField(b)) * sortOrder.order 15 | ); 16 | } 17 | 18 | constructor(appConfig: AppConfigService) { 19 | // http://beta.json-generator.com/api/json/get/VJl5GbIze 20 | super('messages', 'assets/messages.json', appConfig); 21 | } 22 | 23 | byFolder(folder: Folder) { 24 | const searchObject = { folder: folder._id }; 25 | const toFromAttr = ['drafts', 'sent'].indexOf(folder._id) !== -1 ? 'from' : 'to'; 26 | searchObject[toFromAttr] = this.appConfig.emailAddress; 27 | return this.search(searchObject); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/mymessages/mymessages.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | /** 4 | * The main mymessages component. 5 | * 6 | * Renders a list of folders, and has two viewports: 7 | * - messageList: filled with the list of messages for a folder 8 | * - messagecontent: filled with the contents of a single message. 9 | */ 10 | @Component({ 11 | selector: 'app-mymessages', 12 | template: ` 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | `, 26 | styles: [], 27 | standalone: false 28 | }) 29 | export class MymessagesComponent { 30 | @Input() folders: any[]; 31 | 32 | constructor() { } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/mymessages/mymessages.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ComposeComponent } from './compose.component'; 4 | import { MessageComponent } from './message.component'; 5 | import { MessageListComponent } from './message-list.component'; 6 | import { MymessagesComponent } from './mymessages.component'; 7 | import { UIRouterModule } from '@uirouter/angular'; 8 | import { MYMESSAGES_STATES } from './mymessages.states'; 9 | import { FormsModule } from '@angular/forms'; 10 | import { MessagesDataService } from './messages-data.service'; 11 | import { FoldersDataService } from './folders-data.service'; 12 | import { FolderListComponent } from './folder-list.component'; 13 | import { MessageTableComponent } from './message-table.component'; 14 | import { SortMessagesComponent } from './sort-messages.component'; 15 | import { FormatMessagePipe } from './format-message.pipe'; 16 | 17 | @NgModule({ 18 | imports: [ 19 | UIRouterModule.forChild({ states: MYMESSAGES_STATES }), 20 | FormsModule, 21 | CommonModule 22 | ], 23 | declarations: [ 24 | ComposeComponent, 25 | MessageComponent, 26 | MessageListComponent, 27 | MymessagesComponent, 28 | FolderListComponent, 29 | MessageTableComponent, 30 | SortMessagesComponent, 31 | FormatMessagePipe, 32 | ], 33 | providers: [ 34 | MessagesDataService, 35 | FoldersDataService, 36 | ] 37 | }) 38 | export class MymessagesModule { } 39 | -------------------------------------------------------------------------------- /src/app/mymessages/mymessages.states.ts: -------------------------------------------------------------------------------- 1 | import { Ng2StateDeclaration } from '@uirouter/angular'; 2 | import { Transition } from '@uirouter/core'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | import { AppConfigService } from '../global/app-config.service'; 6 | import { ComposeComponent } from './compose.component'; 7 | import { FoldersDataService } from './folders-data.service'; 8 | import { Folder, Message } from './interface'; 9 | import { MessageListComponent } from './message-list.component'; 10 | import { MessageComponent } from './message.component'; 11 | import { MessagesDataService } from './messages-data.service'; 12 | import { MymessagesComponent } from './mymessages.component'; 13 | 14 | export function getFolders(foldersService: FoldersDataService) { 15 | return foldersService.all(); 16 | } 17 | 18 | /** 19 | * The mymessages state. This is the main state for the mymessages submodule. 20 | * 21 | * This state shows the list of folders for the current user. It retrieves the folders from the 22 | * Folders service. If a user navigates directly to this state, the state redirects to the 'mymessages.messagelist'. 23 | */ 24 | export const mymessagesState: Ng2StateDeclaration = { 25 | parent: 'app', 26 | name: 'mymessages', 27 | url: '/mymessages', 28 | component: MymessagesComponent, 29 | resolve: [ 30 | // All the folders are fetched from the Folders service 31 | { token: 'folders', deps: [FoldersDataService], resolveFn: getFolders }, 32 | ], 33 | // If mymessages state is directly activated, redirect the transition to the child state 'mymessages.messagelist' 34 | redirectTo: 'mymessages.messagelist', 35 | // Mark this state as requiring authentication. See ../routerhooks/requiresAuth.js. 36 | data: { requiresAuth: true } 37 | }; 38 | 39 | 40 | export function getFolder(foldersService: FoldersDataService, transition: Transition) { 41 | return foldersService.get(transition.params().folderId); 42 | } 43 | 44 | export function getMessages(messagesService: MessagesDataService, folder: Folder, 45 | appConfig: AppConfigService): Promise> { 46 | const promise = messagesService.byFolder(folder); 47 | 48 | return promise.then(messages => appConfig.sort$.pipe(map(sortOrder => 49 | MessagesDataService.sortedMessages(messages, sortOrder) 50 | ))); 51 | } 52 | 53 | /** 54 | * This state shows the contents (a message list) of a single folder 55 | */ 56 | export const messageListState = { 57 | name: 'mymessages.messagelist', 58 | url: '/:folderId', 59 | // The folderId parameter is part of the URL. This params block sets 'inbox' as the default value. 60 | // If no parameter value for folderId is provided on the transition, then it will be defaulted to 'inbox' 61 | params: { folderId: 'inbox' }, 62 | views: { 63 | // This targets the "messagelist" named ui-view added to the DOM in the parent state 'mymessages' 64 | messagelist: { 65 | component: MessageListComponent, 66 | }, 67 | }, 68 | resolve: [ 69 | // Fetch the current folder from the Folders service, using the folderId parameter 70 | { token: 'folder', deps: [FoldersDataService, Transition], resolveFn: getFolder }, 71 | 72 | // The folder object (from the resolve above) is injected into this resolve 73 | // The list of message for the folder are fetched from the Messages service 74 | { token: 'messages$', deps: [MessagesDataService, 'folder', AppConfigService], resolveFn: getMessages }, 75 | ], 76 | }; 77 | 78 | 79 | export function getMessage(messagesService: MessagesDataService, transition: Transition) { 80 | return messagesService.get(transition.params().messageId); 81 | } 82 | 83 | export function getProximalMessage(messages$: Observable, message: Message) { 84 | return messages$.pipe( 85 | map((messages: Message[]) => { 86 | const curIdx = messages.indexOf(message); 87 | const nextIdx = curIdx === messages.length ? curIdx - 1 : curIdx + 1; 88 | return messages[nextIdx]; 89 | }) 90 | ); 91 | } 92 | 93 | /** 94 | * This state shows the contents of a single message. 95 | * It also has UI to reply, forward, delete, or edit an existing draft. 96 | */ 97 | export const messageState: Ng2StateDeclaration = { 98 | name: 'mymessages.messagelist.message', 99 | url: '/:messageId', 100 | views: { 101 | // Relatively target the parent-state's parent-state's 'messagecontent' ui-view 102 | // This could also have been written using ui-view@state addressing: 'messagecontent@mymessages' 103 | // Or, this could also have been written using absolute ui-view addressing: '!$default.$default.messagecontent' 104 | '^.^.messagecontent': { 105 | component: MessageComponent, 106 | }, 107 | }, 108 | resolve: [ 109 | // Fetch the message from the Messages service using the messageId parameter 110 | { token: 'message', deps: [MessagesDataService, Transition], resolveFn: getMessage }, 111 | 112 | // Provide the component with the next closest message to activate if the current message is deleted 113 | { token: 'proximalMessage$', deps: ['messages$', 'message'], resolveFn: getProximalMessage } 114 | ], 115 | }; 116 | 117 | 118 | /** 119 | * This state allows the user to compose a new message, edit a drafted message, send a message, 120 | * or save an unsent message as a draft. 121 | * 122 | * This state uses view-targeting to take over the ui-view that would normally be filled by the 'mymessages' state. 123 | */ 124 | export const composeState: Ng2StateDeclaration = { 125 | name: 'mymessages.compose', 126 | url: '/compose', 127 | views: { 128 | // Absolutely targets the $default (unnamed) ui-view, two nesting levels down with the composeComponent. 129 | '!$default.$default': { component: ComposeComponent } 130 | }, 131 | // Declares that this state has a 'message' parameter, that defaults to an empty object. 132 | // Note the parameter does not appear in the URL. 133 | params: { 134 | message: {} 135 | }, 136 | }; 137 | 138 | export const MYMESSAGES_STATES = [ 139 | mymessagesState, 140 | messageListState, 141 | messageState, 142 | composeState, 143 | ]; 144 | -------------------------------------------------------------------------------- /src/app/mymessages/sort-messages.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Input, HostListener, OnDestroy } from '@angular/core'; 2 | import { AppConfigService } from '../global/app-config.service'; 3 | import { Subscription } from 'rxjs'; 4 | 5 | /** 6 | * A directive (for a table header) which changes the app's sort order 7 | */ 8 | @Component({ 9 | selector: '[app-sort-messages]', 10 | template: ` 11 | {{ label }} 16 | `, 17 | styles: [], 18 | standalone: false 19 | }) 20 | export class SortMessagesComponent implements OnInit, OnDestroy { 21 | @Input('prop') prop: string; 22 | @Input('label') label: string; 23 | private _sub: Subscription; 24 | public asc: boolean; 25 | public desc: boolean; 26 | 27 | constructor(private appConfig: AppConfigService) { } 28 | 29 | ngOnInit() { 30 | this._sub = this.appConfig.sort$.subscribe(() => this.update()); 31 | } 32 | 33 | ngOnDestroy() { 34 | this._sub.unsubscribe(); 35 | } 36 | 37 | update() { 38 | const sort = this.appConfig.sort$.value; 39 | const matches = sort.sortBy === this.prop; 40 | this.asc = matches && sort.order < 0; 41 | this.desc = matches && sort.order > 0; 42 | } 43 | 44 | @HostListener('click', ['$event']) 45 | onClick(e) { 46 | this.appConfig.sort = (this.asc ? '+' : '-') + this.prop; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/prefs/README.md: -------------------------------------------------------------------------------- 1 | ### The NgModule 2 | 3 | - *prefs.module*.ts: A feature module (NgModule) which manages user preferences 4 | 5 | ## NgModule Contents 6 | 7 | ### The prefs states 8 | 9 | - *prefs.states*.ts: Defines the ui-router states for preferences feature 10 | 11 | ### The prefs components 12 | 13 | - *prefs.component*.js: A component for showing and/or updating user preferences. 14 | -------------------------------------------------------------------------------- /src/app/prefs/prefs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { AppConfigService } from '../global/app-config.service'; 3 | 4 | /** 5 | * A component which shows and updates app preferences 6 | */ 7 | @Component({ 8 | selector: 'app-prefs', 9 | template: ` 10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 |
19 | `, 20 | styles: [], 21 | standalone: false 22 | }) 23 | export class PrefsComponent { 24 | // data 25 | prefs; 26 | 27 | constructor(private appConfig: AppConfigService) { 28 | this.prefs = { 29 | restDelay: appConfig.restDelay 30 | }; 31 | } 32 | 33 | /** Clear out the session storage */ 34 | reset() { 35 | sessionStorage.clear(); 36 | document.location.reload(); 37 | } 38 | 39 | /** After saving preferences to session storage, reload the entire application */ 40 | savePrefs() { 41 | Object.assign(this.appConfig, { restDelay: this.prefs.restDelay }).save(); 42 | document.location.reload(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/prefs/prefs.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { PrefsComponent } from './prefs.component'; 4 | import { prefsState } from './prefs.states'; 5 | import { UIRouterModule } from '@uirouter/angular'; 6 | import { FormsModule } from '@angular/forms'; 7 | 8 | @NgModule({ 9 | imports: [ 10 | UIRouterModule.forChild({ states: [ prefsState ] }), 11 | FormsModule, 12 | CommonModule 13 | ], 14 | declarations: [ 15 | PrefsComponent 16 | ] 17 | }) 18 | export class PrefsModule { } 19 | -------------------------------------------------------------------------------- /src/app/prefs/prefs.states.ts: -------------------------------------------------------------------------------- 1 | import { PrefsComponent } from './prefs.component'; 2 | /** 3 | * This state allows the user to set their application preferences 4 | */ 5 | export const prefsState = { 6 | parent: 'app', 7 | name: 'prefs', 8 | url: '/prefs', 9 | component: PrefsComponent, 10 | // Mark this state as requiring authentication. See ../global/auth.hook 11 | data: { requiresAuth: true } 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/router.config.ts: -------------------------------------------------------------------------------- 1 | import { UIRouter, Category } from '@uirouter/core'; 2 | import { Visualizer } from '@uirouter/visualizer'; 3 | 4 | import { googleAnalyticsHook } from './util/ga'; 5 | import { requiresAuthHook } from './global/auth.hook'; 6 | 7 | export function routerConfigFn(router: UIRouter) { 8 | const transitionService = router.transitionService; 9 | requiresAuthHook(transitionService); 10 | googleAnalyticsHook(transitionService); 11 | 12 | router.trace.enable(Category.TRANSITION); 13 | router.plugin(Visualizer); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/util/README.md: -------------------------------------------------------------------------------- 1 | ## Contents 2 | 3 | ### Utility Code 4 | - *sessionStorage.js*: Provides an API that emulates a RESTful client API 5 | - *util.js*: various utility functions 6 | - *revertableModel*.js: Provides an API allowing an edited object to be reverted -------------------------------------------------------------------------------- /src/app/util/ga.ts: -------------------------------------------------------------------------------- 1 | /** Google analytics */ 2 | /* tslint:disable */ 3 | 4 | import { TransitionService } from '@uirouter/core'; 5 | ((function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 6 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*(new Date());a=s.createElement(o), 7 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 8 | }))(window,document,'script','//www.google-analytics.com/analytics.js','ga'); 9 | 10 | window['ga']('create', 'UA-73329341-1', 'auto'); 11 | window['ga']('send', 'pageview'); 12 | 13 | export function googleAnalyticsHook(transitionService: TransitionService) { 14 | const vpv = (vpath) => 15 | window['ga']('send', 'pageview', vpath); 16 | 17 | const path = (trans) => { 18 | const formattedRoute = trans.$to().url.format(trans.params()); 19 | const withSitePrefix = location.pathname + formattedRoute; 20 | return `/${withSitePrefix.split('/').filter(x => x).join('/')}`; 21 | }; 22 | 23 | const error = (trans) => { 24 | const err = trans.error(); 25 | const type = err && err.hasOwnProperty('type') ? err.type : '_'; 26 | const message = err && err.hasOwnProperty('message') ? err.message : '_'; 27 | vpv(path(trans) + ';errorType=' + type + ';errorMessage=' + message); 28 | }; 29 | 30 | transitionService.onSuccess({}, (trans) => vpv(path(trans)), { priority: -10000 }); 31 | transitionService.onError({}, (trans) => error(trans), { priority: -10000 }); 32 | } 33 | -------------------------------------------------------------------------------- /src/app/util/sessionStorage.ts: -------------------------------------------------------------------------------- 1 | import { pushToArr, guid, wait } from './util'; 2 | import { AppConfigService } from '../global/app-config.service'; 3 | 4 | /** 5 | * This class simulates a RESTful resource, but the API calls fetch data from 6 | * Session Storage instead of an HTTP call. 7 | * 8 | * Once configured, it loads the initial (pristine) data from the URL provided (using HTTP). 9 | * It exposes GET/PUT/POST/DELETE-like API that operates on the data. All the data is also 10 | * stored in Session Storage. If any data is modified in memory, session storage is updated. 11 | * If the browser is refreshed, the SessionStorage object will try to fetch the existing data from 12 | * the session, before falling back to re-fetching the initial data using HTTP. 13 | * 14 | * For an example, please see dataSources.js 15 | */ 16 | export class SessionStorage { 17 | // data 18 | _data: Promise; 19 | _idProp: string; 20 | _eqFn: (a: T, b: T) => boolean; 21 | 22 | /** 23 | * Creates a new SessionStorage object 24 | * 25 | * @param sessionStorageKey The session storage key. The data will be stored in browser's session storage under this key. 26 | * @param sourceUrl The url that contains the initial data. 27 | * @param appConfig Pass in the AppConfig object 28 | */ 29 | constructor(public sessionStorageKey, sourceUrl, public appConfig: AppConfigService) { 30 | let data; 31 | const fromSession = sessionStorage.getItem(sessionStorageKey); 32 | // A promise for *all* of the data. 33 | this._data = undefined; 34 | 35 | // For each data object, the _idProp defines which property has that object's unique identifier 36 | this._idProp = '_id'; 37 | 38 | // A basic triple-equals equality checker for two values 39 | this._eqFn = (l, r) => l[this._idProp] === r[this._idProp]; 40 | 41 | if (fromSession) { 42 | try { 43 | // Try to parse the existing data from the Session Storage API 44 | data = JSON.parse(fromSession); 45 | } catch (e) { 46 | console.log('Unable to parse session messages, retrieving intial data.'); 47 | } 48 | } 49 | 50 | // Create a promise for the data; Either the existing data from session storage, or the initial data via $http request 51 | this._data = (data ? Promise.resolve(data) : fetch(sourceUrl) 52 | .then(resp => resp.json())) 53 | .then(this._commit.bind(this)) 54 | .then(() => JSON.parse(sessionStorage.getItem(sessionStorageKey))); 55 | } 56 | 57 | /** Saves all the data back to the session storage */ 58 | _commit(data: T[]): Promise { 59 | sessionStorage.setItem(this.sessionStorageKey, JSON.stringify(data)); 60 | return Promise.resolve(data); 61 | } 62 | 63 | /** Helper which simulates a delay, then provides the `thenFn` with the data */ 64 | all(): Promise { 65 | const delay = this.appConfig.restDelay; 66 | return wait(delay).then(() => this._data); 67 | } 68 | 69 | /** Given a sample item, returns a promise for all the data for items which have the same properties as the sample */ 70 | search(exampleItem): Promise { 71 | const contains = (search, inString) => 72 | ('' + inString).indexOf('' + search) !== -1; 73 | const matchesExample = (example, item) => 74 | Object.keys(example).reduce((memo, key) => memo && contains(example[key], item[key]), true); 75 | return this.all().then(items => 76 | items.filter(matchesExample.bind(null, exampleItem))); 77 | } 78 | 79 | /** Returns a promise for the item with the given identifier */ 80 | get(id): Promise { 81 | return this.all().then(items => 82 | items.find(item => item[this._idProp] === id)); 83 | } 84 | 85 | /** Returns a promise to save the item. It delegates to put() or post() if the object has or does not have an identifier set */ 86 | save(item: T): Promise { 87 | return item[this._idProp] ? this.put(item) : this.post(item); 88 | } 89 | 90 | /** Returns a promise to save (POST) a new item. The item's identifier is auto-assigned. */ 91 | post(item: T): Promise { 92 | item[this._idProp] = guid(); 93 | return this.all() 94 | .then(items => pushToArr(items, item)) 95 | .then(this._commit.bind(this)) 96 | .then(() => item); 97 | } 98 | 99 | /** Returns a promise to save (PUT) an existing item. */ 100 | put(item: T, eqFn = this._eqFn): Promise { 101 | return this.all().then(items => { 102 | const idx = items.findIndex(eqFn.bind(null, item)); 103 | if (idx === -1) { 104 | throw Error(`${item} not found in ${this}`); 105 | } 106 | items[idx] = item; 107 | return this._commit(items).then(() => item); 108 | }); 109 | } 110 | 111 | /** Returns a promise to remove (DELETE) an item. */ 112 | remove(item: T, eqFn = this._eqFn): Promise { 113 | return this.all().then(items => { 114 | const idx = items.findIndex(eqFn.bind(null, item)); 115 | if (idx === -1) { 116 | throw Error(`${item} not found in ${this}`); 117 | } 118 | items.splice(idx, 1); 119 | return this._commit(items).then(() => item); 120 | }); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/app/util/util.ts: -------------------------------------------------------------------------------- 1 | import { pattern, isObject, identity, val } from '@uirouter/core'; 2 | /** Some utility functions used by the application */ 3 | 4 | export const setProp = (obj, key, val) => { obj[key] = val; return obj; }; 5 | export const pushToArr = (array, item) => { array.push(item); return array; }; 6 | export const uniqReduce = (arr, item) => arr.indexOf(item) !== -1 ? arr : pushToArr(arr, item); 7 | export const flattenReduce = (arr, item) => arr.concat(item); 8 | const guidChar = (c) => c !== 'x' && c !== 'y' ? '-' : Math.floor(Math.random() * 16).toString(16).toUpperCase(); 9 | export const guid = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.split('').map(guidChar).join(''); 10 | // A function that returns a promise which resolves after a timeout 11 | export const wait = (delay) => new Promise(resolve => setTimeout(resolve, delay)); 12 | 13 | export const copy = pattern([ 14 | [Array.isArray, val => val.map(copy)], 15 | [isObject, val => Object.keys(val).reduce((acc, key) => (acc[key] = copy(val[key]), acc), {})], 16 | [val(true), identity ] 17 | ]); 18 | 19 | -------------------------------------------------------------------------------- /src/app/welcome.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-welcome', 5 | template: ` 6 |
7 | 8 |

UI-Router Sample App

9 | 10 |

Welcome to the sample app!

11 |

This is a demonstration app intended to highlight some patterns that can be used within UI-Router. 12 | These patterns should help you to to build cohesive, robust apps. Additionally, this app uses state-vis 13 | to show the tree of states, and a transition log visualizer.

14 | 15 |

App Overview

16 |

17 | First, start exploring the application's functionality at a high level by activating 18 | one of the three submodules: Messages, Contacts, or Preferences. If you are not already logged in, 19 | you will be taken to an authentication screen (the authentication is fake; the password is "password") 20 |

21 |
22 | 23 | 24 | 25 |
26 | 27 |

Patterns and Recipes

28 |
    29 |
  • Require Authentication
  • 30 |
  • Previous State
  • 31 |
  • Redirect Hook
  • 32 |
  • Default Param Values
  • 33 |
34 |
35 | `, 36 | styles: [], 37 | standalone: false 38 | }) 39 | export class WelcomeComponent implements OnInit { 40 | constructor() { } 41 | 42 | ngOnInit() { 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/README.md: -------------------------------------------------------------------------------- 1 | ## Contents 2 | 3 | This directory contains the fake data used by the fake REST services. 4 | 5 | ### Data 6 | - *contacts.json*: The users' contacts (currently they share the contact list) 7 | - *folders.json*: Folder list (and meta-data, such as columns to display for a folder) 8 | - *messages.json*: The users' messages 9 | - *corpora*: Directory containing markov chain seed corpora for generating styles of messages 10 | 11 | ### Scripts 12 | - *fetch.sh*: Fetches original contacts list and non-markov messages from json-generator.com 13 | - *generate.sh*: Fetches `jsmarkov` project and re-generates markov messages 14 | - *generate.js*: Driver code for `jsmarkov` 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/assets/contacts.json: -------------------------------------------------------------------------------- 1 | [{"tags":[7,"consectetur"],"address":{"zip":42272,"state":"Washington","city":"Linganore","street":"394 Nova Court"},"phone":"+1 (856) 490-2330","email":"finley.ruiz@enersave.info","company":"ENERSAVE","age":33,"picture":"http://placebear.com/187/187","_id":"fruiz","name":{"last":"Ruiz","first":"Finley"}},{"tags":[7,"consectetur"],"address":{"zip":47608,"state":"Delaware","city":"Conestoga","street":"446 Cortelyou Road"},"phone":"+1 (864) 592-2641","email":"mitzi.kinney@zeam.net","company":"ZEAM","age":36,"picture":"http://placebear.com/149/250","_id":"mkinney","name":{"last":"Kinney","first":"Mitzi"}},{"tags":[7,"consectetur"],"address":{"zip":26182,"state":"Indiana","city":"Carrizo","street":"731 Brooklyn Road"},"phone":"+1 (984) 587-2537","email":"sallie.mann@namegen.ca","company":"NAMEGEN","age":20,"picture":"http://placebear.com/184/249","_id":"smann","name":{"last":"Mann","first":"Sallie"}},{"tags":[7,"consectetur"],"address":{"zip":13964,"state":"Georgia","city":"Cassel","street":"662 Bristol Street"},"phone":"+1 (822) 581-2476","email":"mckay.hatfield@idego.me","company":"IDEGO","age":29,"picture":"http://placebear.com/138/189","_id":"mhatfield","name":{"last":"Hatfield","first":"Mckay"}},{"tags":[7,"consectetur"],"address":{"zip":83300,"state":"Mississippi","city":"Newry","street":"945 Noble Street"},"phone":"+1 (956) 598-2570","email":"delacruz.avila@anixang.org","company":"ANIXANG","age":23,"picture":"http://placebear.com/152/236","_id":"davila","name":{"last":"Avila","first":"Delacruz"}},{"tags":[7,"consectetur"],"address":{"zip":59368,"state":"South Dakota","city":"Gulf","street":"825 Sapphire Street"},"phone":"+1 (810) 511-2092","email":"vonda.tran@corpulse.io","company":"CORPULSE","age":26,"picture":"http://placebear.com/137/213","_id":"vtran","name":{"last":"Tran","first":"Vonda"}},{"tags":[7,"consectetur"],"address":{"zip":14422,"state":"Oklahoma","city":"Breinigsville","street":"522 Court Square"},"phone":"+1 (939) 402-3205","email":"chandler.christensen@xth.co.uk","company":"XTH","age":24,"picture":"http://placebear.com/156/197","_id":"cchristensen","name":{"last":"Christensen","first":"Chandler"}},{"tags":[7,"consectetur"],"address":{"zip":22182,"state":"New Mexico","city":"Wyoming","street":"524 Gunnison Court"},"phone":"+1 (919) 508-2916","email":"turner.cameron@shadease.tv","company":"SHADEASE","age":31,"picture":"http://placebear.com/160/210","_id":"tcameron","name":{"last":"Cameron","first":"Turner"}},{"tags":[7,"consectetur"],"address":{"zip":66668,"state":"Iowa","city":"Duryea","street":"976 Beadel Street"},"phone":"+1 (989) 510-2305","email":"underwood.owens@entropix.us","company":"ENTROPIX","age":33,"picture":"http://placebear.com/152/217","_id":"uowens","name":{"last":"Owens","first":"Underwood"}},{"tags":[7,"consectetur"],"address":{"zip":53900,"state":"District Of Columbia","city":"Bison","street":"863 Fillmore Place"},"phone":"+1 (816) 519-2068","email":"rios.sears@telpod.biz","company":"TELPOD","age":30,"picture":"http://placebear.com/200/201","_id":"rsears","name":{"last":"Sears","first":"Rios"}},{"tags":[7,"consectetur"],"address":{"zip":37214,"state":"Connecticut","city":"Omar","street":"304 Fay Court"},"phone":"+1 (847) 505-2431","email":"rojas.combs@maroptic.biz","company":"MAROPTIC","age":31,"picture":"http://placebear.com/173/244","_id":"rcombs","name":{"last":"Combs","first":"Rojas"}},{"tags":[7,"consectetur"],"address":{"zip":34154,"state":"New Jersey","city":"Dale","street":"441 Judge Street"},"phone":"+1 (857) 571-3040","email":"ester.morgan@zolarity.name","company":"ZOLARITY","age":38,"picture":"http://placebear.com/160/230","_id":"emorgan","name":{"last":"Morgan","first":"Ester"}},{"tags":[7,"consectetur"],"address":{"zip":13228,"state":"West Virginia","city":"Trinway","street":"250 Dinsmore Place"},"phone":"+1 (907) 487-2234","email":"maribel.prince@tripsch.info","company":"TRIPSCH","age":31,"picture":"http://placebear.com/185/195","_id":"mprince","name":{"last":"Prince","first":"Maribel"}},{"tags":[7,"consectetur"],"address":{"zip":24366,"state":"Hawaii","city":"Moscow","street":"350 Homecrest Court"},"phone":"+1 (859) 548-3945","email":"concetta.higgins@mitroc.net","company":"MITROC","age":32,"picture":"http://placebear.com/164/217","_id":"chiggins","name":{"last":"Higgins","first":"Concetta"}},{"tags":[7,"consectetur"],"address":{"zip":26951,"state":"Puerto Rico","city":"Kent","street":"958 Granite Street"},"phone":"+1 (811) 591-3229","email":"delia.hunter@fortean.ca","company":"FORTEAN","age":27,"picture":"http://placebear.com/178/228","_id":"dhunter","name":{"last":"Hunter","first":"Delia"}},{"tags":[7,"consectetur"],"address":{"zip":91136,"state":"Illinois","city":"Kraemer","street":"225 Bushwick Court"},"phone":"+1 (965) 588-3336","email":"marlene.wilkinson@halap.me","company":"HALAP","age":38,"picture":"http://placebear.com/187/209","_id":"mwilkinson","name":{"last":"Wilkinson","first":"Marlene"}},{"tags":[7,"consectetur"],"address":{"zip":29582,"state":"Utah","city":"Edinburg","street":"563 Bartlett Place"},"phone":"+1 (994) 428-2015","email":"marsha.dillon@codax.org","company":"CODAX","age":20,"picture":"http://placebear.com/195/222","_id":"mdillon","name":{"last":"Dillon","first":"Marsha"}},{"tags":[7,"consectetur"],"address":{"zip":18460,"state":"New York","city":"Cornucopia","street":"351 Irvington Place"},"phone":"+1 (860) 416-3012","email":"amparo.cooke@permadyne.io","company":"PERMADYNE","age":24,"picture":"http://placebear.com/184/193","_id":"acooke","name":{"last":"Cooke","first":"Amparo"}},{"tags":[7,"consectetur"],"address":{"zip":56073,"state":"Missouri","city":"Guilford","street":"581 Canton Court"},"phone":"+1 (866) 578-3557","email":"stacey.oneal@netur.co.uk","company":"NETUR","age":27,"picture":"http://placebear.com/133/205","_id":"soneal","name":{"last":"Oneal","first":"Stacey"}},{"tags":[7,"consectetur"],"address":{"zip":49686,"state":"Marshall Islands","city":"Catharine","street":"432 Hart Place"},"phone":"+1 (875) 582-3295","email":"sharron.brennan@supremia.tv","company":"SUPREMIA","age":20,"picture":"http://placebear.com/154/209","_id":"sbrennan","name":{"last":"Brennan","first":"Sharron"}},{"tags":[7,"consectetur"],"address":{"zip":81298,"state":"Federated States Of Micronesia","city":"Columbus","street":"765 Keen Court"},"phone":"+1 (813) 420-2694","email":"christian.grimes@bezal.us","company":"BEZAL","age":23,"picture":"http://placebear.com/193/201","_id":"cgrimes","name":{"last":"Grimes","first":"Christian"}},{"tags":[7,"consectetur"],"address":{"zip":68785,"state":"Arkansas","city":"Warsaw","street":"124 Dooley Street"},"phone":"+1 (871) 496-2198","email":"johnnie.cardenas@ontality.biz","company":"ONTALITY","age":38,"picture":"http://placebear.com/172/208","_id":"jcardenas","name":{"last":"Cardenas","first":"Johnnie"}},{"tags":[7,"consectetur"],"address":{"zip":54344,"state":"Maryland","city":"Cedarville","street":"473 Utica Avenue"},"phone":"+1 (946) 593-2302","email":"henson.ware@zolarex.biz","company":"ZOLAREX","age":32,"picture":"http://placebear.com/131/247","_id":"hware","name":{"last":"Ware","first":"Henson"}},{"tags":[7,"consectetur"],"address":{"zip":13348,"state":"North Dakota","city":"Ellerslie","street":"393 Rock Street"},"phone":"+1 (817) 496-2776","email":"gilmore.alvarado@veraq.name","company":"VERAQ","age":27,"picture":"http://placebear.com/134/248","_id":"galvarado","name":{"last":"Alvarado","first":"Gilmore"}},{"tags":[7,"consectetur"],"address":{"zip":51906,"state":"Colorado","city":"Stevens","street":"739 Scholes Street"},"phone":"+1 (801) 569-3285","email":"salazar.craig@zilencio.info","company":"ZILENCIO","age":28,"picture":"http://placebear.com/179/182","_id":"scraig","name":{"last":"Craig","first":"Salazar"}}] -------------------------------------------------------------------------------- /src/assets/corpora/2nd-treatise.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/2nd-treatise.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/beatles.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/beatles.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/beowulf.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/beowulf.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/bsdfaq.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/bsdfaq.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/cat-in-the-hat.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/cat-in-the-hat.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/comm_man.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/comm_man.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/elflore.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/elflore.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/flatland.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/flatland.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/green-eggs.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/green-eggs.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/macbeth.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/macbeth.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/palin.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/palin.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/rfc2549.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/rfc2549.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/rfc7230.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/rfc7230.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/sneetches.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/sneetches.txt.gz -------------------------------------------------------------------------------- /src/assets/corpora/two-cities.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/assets/corpora/two-cities.txt.gz -------------------------------------------------------------------------------- /src/assets/fetch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | curl -o messages.txt http://beta.json-generator.com/api/json/get/VJl5GbIze 3 | curl -o contacts.json http://beta.json-generator.com/api/json/get/V1g6UwwGx 4 | -------------------------------------------------------------------------------- /src/assets/folders.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "inbox", 4 | "columns": [ "read", "from", "subject", "date" ], 5 | "actions": [ "reply", "forward", "delete" ] 6 | }, 7 | { 8 | "_id": "finance", 9 | "columns": [ "read", "from", "subject", "date" ], 10 | "actions": [ "reply", "forward", "delete" ] 11 | }, 12 | { 13 | "_id": "travel", 14 | "columns": [ "read", "from", "subject", "date" ], 15 | "actions": [ "reply", "forward", "delete" ] 16 | }, 17 | { 18 | "_id": "personal", 19 | "columns": [ "read", "from", "subject", "date" ], 20 | "actions": [ "reply", "forward", "delete" ] 21 | }, 22 | { 23 | "_id": "spam", 24 | "columns": [ "read", "from", "subject", "date" ], 25 | "actions": [ "reply", "forward", "delete" ] 26 | }, 27 | { 28 | "_id": "drafts", 29 | "columns": [ "read", "to", "subject", "date" ], 30 | "actions": [ "edit", "delete" ] 31 | }, 32 | { 33 | "_id": "sent", 34 | "columns": [ "read", "to", "subject", "date" ], 35 | "actions": [ "forward", "delete" ] 36 | } 37 | ] -------------------------------------------------------------------------------- /src/assets/generate.js: -------------------------------------------------------------------------------- 1 | var Markov = require('./markov').Markov; 2 | 3 | var zlib = require('zlib'), 4 | fs = require('fs'); 5 | var path = require('path'); 6 | 7 | 8 | var currentMarkov; 9 | var markovs = {}; 10 | var messages = JSON.parse(fs.readFileSync('messages.txt')); 11 | messages.forEach(function(message) { 12 | currentMarkov = getMarkov(message.corpus); 13 | message.subject = sen2gibberish(1, message.subject); 14 | var pars = message.body.split("\n"); 15 | var sig = pars.slice(-2); 16 | message.body = msg2gibberish(message.body) + '\n' + sig.join("\n"); 17 | }); 18 | fs.writeFile('messages.json', JSON.stringify(messages), 'utf-8'); 19 | 20 | 21 | function sen2gibberish(mult, sentence) { 22 | var words = sentence.split(/\s+/).length * mult; 23 | var start = currentMarkov.randomStart(); 24 | var gibberish = currentMarkov.generate(start, words); 25 | 26 | var sentence = gibberish; 27 | 28 | while (sentence.match(/^ *[,.?!]/)) { 29 | var words = sentence.split(/\s/); 30 | var newWord = currentMarkov.generate(words.slice(-2), 1); 31 | sentence = words.slice(1).join(" ") + newWord; 32 | } 33 | 34 | sentence = sentence.replace(/ ([^\w])/g, "$1"); 35 | 36 | sentence = sentence.replace(/[!?.] [a-z]/g, function (txt) { 37 | var len = txt.length; 38 | return txt.substr(0, len-1) + txt.charAt(len - 1).toUpperCase(); 39 | }); 40 | 41 | sentence = sentence.trim().replace(/\w.+/, function(txt){ 42 | return txt.charAt(0).toUpperCase() + txt.substr(1); 43 | }); 44 | 45 | console.log("sentence: " + sentence); 46 | return sentence + "."; 47 | } 48 | 49 | function par2gibberish(paragraph) { 50 | return paragraph.split(/\./) 51 | .map(sen2gibberish.bind(null, 2)) 52 | .join(" "); 53 | } 54 | 55 | function msg2gibberish(message) { 56 | return message.split("\n") 57 | .filter(function(s) { return s.trim().length; }) 58 | .map(par2gibberish) 59 | .join("\n"); 60 | } 61 | 62 | 63 | 64 | function getMarkov(corpus) { 65 | if (!markovs[corpus]) { 66 | markovs[corpus] = new Markov(); 67 | var inputFile = path.join(__dirname, '/corpora/', corpus + '.txt'); 68 | var text = fs.readFileSync(inputFile, 'utf8'); 69 | text = text.replace(/([^\s\w.,?!])/g, ""); 70 | text = text.replace(/([.,?!])/g, " $1 "); 71 | console.log("*** Training markov corpus with " + corpus); 72 | markovs[corpus].train(text); 73 | } 74 | return markovs[corpus]; 75 | } 76 | 77 | -------------------------------------------------------------------------------- /src/assets/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ./fetch.sh 3 | curl -O https://raw.githubusercontent.com/banksean/jsmarkov/master/markov.js 4 | curl -O https://raw.githubusercontent.com/banksean/jsmarkov/master/probabilityset.js 5 | 6 | gunzip -f -k corpora/*.gz 7 | 8 | cat probabilityset.js >> markov.js 9 | echo >> markov.js 10 | echo "exports.Markov = Markov;" >> markov.js 11 | 12 | node generate 13 | 14 | rm markov.js probabilityset.js 15 | rm corpora/*.txt 16 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ui-router/sample-app-angular/3a7add8438549c80556cf5b420663b48bc5d59b7/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SampleAppNg2 6 | 7 | 8 | 9 | 10 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | import { enableProdMode } from '@angular/core'; 3 | import { environment } from './environments/environment'; 4 | import { AppModule } from './app/app.module'; 5 | 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule); 12 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js'; 2 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | button.btn i + span { 4 | margin-left: 0.75em; 5 | } 6 | 7 | .flex-h { 8 | display: flex; 9 | flex-flow: row wrap; 10 | } 11 | 12 | .flex-v { 13 | display: flex; 14 | flex-flow: column wrap; 15 | } 16 | 17 | .flex-h > * { 18 | flex: 1 1; 19 | } 20 | 21 | .flex.grow { 22 | flex: 1 0 auto; 23 | } 24 | 25 | .flex.nogrow { 26 | flex: 0 1 auto; 27 | } 28 | 29 | 30 | .home.buttons { 31 | display: flex; 32 | flex-flow: row wrap; 33 | justify-content: space-around; 34 | align-items: center; 35 | height: 45%; 36 | } 37 | 38 | .home.buttons button { 39 | padding: 2em; 40 | margin: 0; 41 | border: 3px solid black; 42 | border-radius: 1em; 43 | text-shadow: 0.1em 0.1em 0.1em black; 44 | } 45 | 46 | .home.buttons button i { 47 | padding: 0; 48 | margin: 0; 49 | text-shadow: 0.1em 0.1em 0.1em black; 50 | } 51 | 52 | .navheader { 53 | margin: 0.3em 0.3em 0 0.3em; 54 | } 55 | 56 | .navheader .nav-tabs li.active a:focus { 57 | background-color: #f7f7f7; 58 | } 59 | 60 | .navheader .nav-tabs li.active a { 61 | border-color: lightgray lightgray rgba(0,0,0,0) lightgray ; 62 | background-color: #f7f7f7; 63 | } 64 | 65 | .navheader .nav-tabs li a { 66 | border-width: 1px; 67 | border-radius: 10px 10px 0 0; 68 | } 69 | 70 | .navheader .logged-in-user div.hoverdrop { 71 | display: none; 72 | position: absolute; 73 | width: 100%; 74 | } 75 | 76 | .navheader .logged-in-user:hover div.hoverdrop { 77 | display: flex; 78 | flex-direction:row-reverse; 79 | } 80 | 81 | /* my messages */ 82 | 83 | .my-messages { 84 | width: 100%; 85 | display: flex; 86 | max-height: 200px; 87 | } 88 | 89 | .my-messages .folderlist { 90 | flex: 0 0 auto; 91 | overflow-y: scroll; 92 | max-height: 200px; 93 | } 94 | 95 | 96 | 97 | 98 | /* selection lists */ 99 | .selectlist { 100 | margin: 0; 101 | padding: 1.5em 0; 102 | background-color: #DDD; 103 | } 104 | .selectlist a { 105 | display: block; 106 | color: black; 107 | padding: 0.15em 1.5em; 108 | text-decoration: none; 109 | font-size: small; 110 | } 111 | .selectlist .selected { 112 | background-color: cornflowerblue; 113 | } 114 | .selectlist .selected a { 115 | color: white; 116 | } 117 | .selectlist i.fa { 118 | width: 1.35em; 119 | } 120 | 121 | 122 | 123 | /* folder list */ 124 | .selectlist .folder.selected i.fa:before { 125 | content: "\f115"; 126 | } 127 | .selectlist .folder:not(.selected) i.fa:before { 128 | content: "\f114"; 129 | } 130 | 131 | 132 | 133 | .ellipsis { 134 | text-overflow: ellipsis; 135 | } 136 | 137 | 138 | 139 | 140 | /* message list */ 141 | 142 | .messagelist { 143 | flex: 1 0 12em; 144 | overflow-y: scroll; 145 | max-height: 200px; 146 | -webkit-user-select: none; 147 | -moz-user-select: none; 148 | -ms-user-select: none; 149 | user-select: none; 150 | } 151 | 152 | .messagelist table { 153 | table-layout: fixed; 154 | width:100%; 155 | } 156 | 157 | .messagelist thead td { 158 | font-weight: bold; 159 | } 160 | 161 | .messagelist thead td.st-sort-ascent:after { 162 | content: " \f0de"; 163 | font-family: "FontAwesome"; 164 | } 165 | 166 | .messagelist thead td.st-sort-descent:after { 167 | content: " \f0dd"; 168 | font-family: "FontAwesome"; 169 | } 170 | 171 | .messagelist table td { 172 | padding: 0.25em 0.75em; 173 | white-space: nowrap; 174 | overflow: hidden; 175 | text-overflow: ellipsis; 176 | font-size: small; 177 | cursor: default; 178 | } 179 | 180 | .messagelist i.fa-circle { 181 | color: cornflowerblue; 182 | font-size: 50%; 183 | } 184 | 185 | .messagelist table tr.active { 186 | background-color: cornflowerblue; 187 | color: white; 188 | } 189 | 190 | .messagelist table td:nth-child(1){ width: 1.75em; } 191 | .messagelist table td:nth-child(2){ width: 21%; } 192 | .messagelist table td:nth-child(3){ width: 62%; } 193 | .messagelist table td:nth-child(4){ width: 15%; } 194 | 195 | .messagelist thead tr:first-child { 196 | /*position:absolute;*/ 197 | } 198 | 199 | .messagelist tbody tr:first-child td{ 200 | /*padding-top:28px;*/ 201 | } 202 | 203 | 204 | 205 | 206 | /* message content */ 207 | 208 | .message .header,.body { 209 | padding: 1em 2em; 210 | } 211 | 212 | .message .header { 213 | background-color: darkgray; 214 | display: flex; 215 | flex-flow: row wrap; 216 | justify-content: space-between; 217 | align-items: baseline; 218 | } 219 | 220 | 221 | .message .header .line2 { 222 | display: flex; 223 | flex-flow: column; 224 | justify-content: space-between; 225 | } 226 | 227 | 228 | .compose .header label { 229 | padding: 0 1em; 230 | flex: 0 0 6em; 231 | } 232 | 233 | .compose .body { 234 | background-color: lightgray; 235 | height: 100%; 236 | } 237 | .compose .body textarea { 238 | width: 100%; 239 | border: 0; 240 | outline: 0; 241 | } 242 | 243 | .compose .body .buttons { 244 | padding: 1em 0; 245 | float: right; 246 | } 247 | 248 | 249 | 250 | 251 | 252 | .contact { 253 | padding: 1em 3em; 254 | } 255 | 256 | .contact .details > div { 257 | display: flex; 258 | flex-flow: row wrap; 259 | } 260 | 261 | .contact .details h3 { 262 | margin-top: 0; 263 | } 264 | 265 | .contact .details label { 266 | width: 8em; 267 | flex: 0 1 auto; 268 | } 269 | 270 | .contact .details input { 271 | flex: 1 1 auto; 272 | } 273 | 274 | 275 | 276 | 277 | 278 | .dialog { 279 | position: fixed; 280 | top: 0; 281 | right: 0; 282 | bottom: 0; 283 | left: 0; 284 | z-index: 1040; 285 | } 286 | 287 | .dialog .backdrop { 288 | position: fixed; 289 | top: 0; 290 | right: 0; 291 | bottom: 0; 292 | left: 0; 293 | transition: opacity 0.25s ease; 294 | background: #000; 295 | opacity: 0; 296 | } 297 | 298 | .dialog.active .backdrop { 299 | opacity: 0.5; 300 | } 301 | 302 | .dialog .wrapper { 303 | z-index: 1041; 304 | opacity: 1; 305 | display: flex; 306 | flex-direction: column; 307 | align-items: center; 308 | } 309 | 310 | .dialog .content { 311 | transition: top 0.25s ease; 312 | flex: 1 1 auto; 313 | background: white; 314 | padding: 2em; 315 | position: relative; 316 | top: -200px; 317 | } 318 | 319 | .dialog.active .content { 320 | top: 4em; 321 | } 322 | 323 | 324 | .bounce-horizontal { 325 | animation: bounce-horizontal 0.5s alternate infinite; 326 | } 327 | 328 | @keyframes bounce-horizontal { 329 | 0% { left: 1.5em; } 330 | 100% { left: 0.5em; } 331 | } 332 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy.js'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | import { getTestBed } from '@angular/core/testing'; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from '@angular/platform-browser-dynamic/testing'; 14 | 15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 16 | declare var __karma__: any; 17 | 18 | // Prevent Karma from running prematurely. 19 | __karma__.loaded = function () {}; 20 | 21 | // First, initialize the Angular testing environment. 22 | getTestBed().initTestEnvironment( 23 | BrowserDynamicTestingModule, 24 | platformBrowserDynamicTesting() 25 | ); 26 | // Finally, start Karma to run the tests. 27 | __karma__.start(); 28 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "", 4 | "declaration": false, 5 | "downlevelIteration": true, 6 | "experimentalDecorators": true, 7 | "lib": [ 8 | "es6", 9 | "dom" 10 | ], 11 | "mapRoot": "./", 12 | "module": "es2020", 13 | "moduleResolution": "node", 14 | "outDir": "../dist/out-tsc", 15 | "sourceMap": true, 16 | "target": "ES2022", 17 | "skipLibCheck": true, 18 | "typeRoots": [ 19 | "../node_modules/@types" 20 | ], 21 | "useDefineForClassFields": false 22 | }, 23 | "files": [ 24 | "main.ts", 25 | "polyfills.ts" 26 | ], 27 | "include": [ 28 | "src/**/*.d.ts" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s language server to improve development experience. 3 | It is not intended to be used to perform a compilation. 4 | 5 | To learn more about this file see: https://angular.io/config/solution-tsconfig. 6 | */ 7 | { 8 | "compilerOptions": { 9 | "esModuleInterop": true 10 | }, 11 | "files": [], 12 | "references": [ 13 | { 14 | "path": "./src/tsconfig.json" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "callable-types": true, 7 | "class-name": true, 8 | "comment-format": [ 9 | true, 10 | "check-space" 11 | ], 12 | "curly": true, 13 | "eofline": true, 14 | "forin": true, 15 | "import-blacklist": [true], 16 | "import-spacing": true, 17 | "indent": [ 18 | true, 19 | "spaces" 20 | ], 21 | "interface-over-type-literal": true, 22 | "label-position": true, 23 | "max-line-length": [ 24 | true, 25 | 140 26 | ], 27 | "member-access": false, 28 | "member-ordering": [ 29 | true, 30 | "static-before-instance", 31 | "variables-before-functions" 32 | ], 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-console": [ 36 | true, 37 | "debug", 38 | "info", 39 | "time", 40 | "timeEnd", 41 | "trace" 42 | ], 43 | "no-construct": true, 44 | "no-debugger": true, 45 | "no-duplicate-variable": true, 46 | "no-empty": false, 47 | "no-empty-interface": true, 48 | "no-eval": true, 49 | "no-inferrable-types": true, 50 | "no-shadowed-variable": true, 51 | "no-string-literal": false, 52 | "no-string-throw": true, 53 | "no-switch-case-fall-through": true, 54 | "no-trailing-whitespace": true, 55 | "no-unused-expression": true, 56 | "no-use-before-declare": true, 57 | "no-var-keyword": true, 58 | "object-literal-sort-keys": false, 59 | "one-line": [ 60 | true, 61 | "check-open-brace", 62 | "check-catch", 63 | "check-else", 64 | "check-whitespace" 65 | ], 66 | "prefer-const": true, 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "radix": true, 72 | "semicolon": [ 73 | "always" 74 | ], 75 | "triple-equals": [ 76 | true, 77 | "allow-null-check" 78 | ], 79 | "typedef-whitespace": [ 80 | true, 81 | { 82 | "call-signature": "nospace", 83 | "index-signature": "nospace", 84 | "parameter": "nospace", 85 | "property-declaration": "nospace", 86 | "variable-declaration": "nospace" 87 | } 88 | ], 89 | "typeof-compare": true, 90 | "unified-signatures": true, 91 | "variable-name": false, 92 | "whitespace": [ 93 | true, 94 | "check-branch", 95 | "check-decl", 96 | "check-operator", 97 | "check-separator", 98 | "check-type" 99 | ], 100 | 101 | "directive-selector": [true, "attribute", "app", "camelCase"], 102 | "component-selector": [true, "element", "app", "kebab-case"], 103 | "no-inputs-metadata-property": true, 104 | "no-outputs-metadata-property": true, 105 | "no-host-metadata-property": true, 106 | "no-input-rename": true, 107 | "no-output-rename": true, 108 | "use-lifecycle-interface": true, 109 | "use-pipe-transform-interface": true, 110 | "component-class-suffix": true, 111 | "directive-class-suffix": true, 112 | "no-access-missing-member": true, 113 | "templates-use-public": true, 114 | "invoke-injectable": true 115 | } 116 | } 117 | --------------------------------------------------------------------------------