Build an AngularJS Help Desk Board

Build an AngularJS 1.8 help desk board with ticket triage and components.

Introduction

30 Second Summary

Support queues change by the minute. A new issue can disappear beneath older work before anyone knows what still needs attention.

In this project, you will build a local help desk board with AngularJS 1.8.2. Every ticket change appears immediately in the browser.

What You'll Build

Your finished browser board turns each typed issue into a trackable ticket while the triage view responds instantly.

By the end of this project, you'll have:

  • Enter an issue into a validated ticket form. The live preview mirrors your text immediately.
  • Add identical issue titles as separate ticket rows. Each row keeps its own action buttons.
  • Manage each ticket's status from its row. The triage view updates its filter and counts immediately.
  • Secret Mission: Extract priority display into a reusable component with one-way binding.

Are there any prerequisites?

You need basic familiarity with HTML and JavaScript.

An internet connection is required while AngularJS loads from Google Hosted Libraries in Microsoft Edge or Google Chrome.

Before We Start

This checkpoint helps you connect the help desk board to a real legacy maintenance skill. You are building it to practise maintaining component-based AngularJS 1.8 applications.

Bootstrap AngularJS in the Browser

A minimal browser bootstrap gives you one clear proof that AngularJS has loaded. That proof keeps later form bugs separate from setup problems.

The result is a local three-file page that Microsoft Edge can open directly. Its first visible signal is the exact AngularJS version.

In this step, get ready to:
  • Create the local project folder with its three files.
  • Bootstrap the ticketDesk AngularJS module.
  • Open the page directly in a browser to verify the framework version.
Create the project files

The project keeps its HTML structure in index.html. JavaScript behavior lives in app.js. Styling lives in styles.css.

  • Press the Windows key to open the Windows search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.

Visual Studio Code gives the project one workspace in its Explorer view. Keeping all three files together also makes their relative links work.

  • Click File in the top menu.
  • Select Open Folder....
  • Select Desktop in the folder window.
  • Select New Folder.
  • Enter angularjs-help-desk as the folder name.
  • Press Enter to create the folder.
  • Select Select Folder to open the folder in Visual Studio Code.
  • Select Yes, I trust the authors if the Workspace Trust prompt appears.

You should see angularjs-help-desk at the top of the Explorer sidebar.

  • Select New File... at the top of the Explorer sidebar.
  • Enter index.html as the file name.
  • Press Enter to create the file.
  • Select New File... again.
  • Enter app.js as the file name.
  • Press Enter to create the file.

The Explorer now lists index.html beside app.js.

  • Select New File... once more.
  • Enter styles.css as the file name.
  • Press Enter to create the file.

You should now see index.html, app.js, plus styles.css inside angularjs-help-desk.

Load and bootstrap AngularJS

The ng-app directive tells AngularJS which module to bootstrap. The script from Google Hosted Libraries must load over HTTPS before app.js runs.

Why use a direct script?

A direct script keeps this browser-only legacy project focused on AngularJS. The exact version URL also keeps the framework release predictable.

  • Select index.html in the Explorer sidebar.
  • Fill index.html by pasting the code below:
<!doctype html>
<html lang="en" ng-app="ticketDesk">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Legacy Help Desk Board</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <ticket-board></ticket-board>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  <script src="app.js"></script>
</body>
</html>

What does this page do?

  • The ng-app="ticketDesk" directive asks AngularJS to bootstrap the matching module.
  • The <ticket-board> element gives the component a place to render.
  • The first script loads AngularJS 1.8.2 from Google Hosted Libraries.
  • The second script loads your application after the framework is available.
  • Save index.html by pressing Ctrl+S.
  • Confirm the unsaved indicator disappears from the index.html editor tab.

Seeing highlighted HTML problems?

Compare every opening tag with its closing tag. Check that both script elements remain inside the body.

Ask for help with the page structure: help me compare my index.html with the required AngularJS bootstrap file.

An AngularJS component packages its template with a controller. The default controller alias is $ctrl.

  • Select app.js in the Explorer sidebar.
  • Fill app.js by pasting the code below:
(function () {
  'use strict';

  angular
    .module('ticketDesk', [])
    .component('ticketBoard', {
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
          '</header>',
        '</main>'
      ].join(''),
      controller: TicketBoardController
    });

  function TicketBoardController() {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
  }
}());

What does this application code do?

  • The ticketDesk module becomes the application named by ng-app.
  • The ticketBoard component maps to the <ticket-board> element.
  • The template uses AngularJS interpolation to display $ctrl.frameworkVersion.
  • The controller reads the runtime version from angular.version.full.
  • Save app.js by pressing Ctrl+S.

Before you open the page, do you expect the browser to show a custom element name or the component template?

  • Press the Windows key to open the Windows search bar.
  • Type Microsoft Edge into the search bar.
  • Press Enter to open Microsoft Edge.
  • Press Ctrl+O to choose a file from your computer.
  • Select Desktop in the file window.
  • Open the angularjs-help-desk folder.
  • Select index.html.
  • Select Open to load the page.

You should see AngularJS 1.8.2 above the Legacy Help Desk Board heading.

That first browser render proves the framework has loaded. Your custom component is now replacing <ticket-board> with its template.

Page blank or missing the version?

  • Confirm your computer has an internet connection for the hosted AngularJS script.
  • Check that the AngularJS script appears before app.js in index.html.
  • Check that ng-app="ticketDesk" matches .module('ticketDesk', []).

Ask for help with the bootstrap: help me find why my local AngularJS page is blank.

Show the framework version

The controller stores the runtime value from angular.version.full on $ctrl.frameworkVersion. The template reads that property inside double braces.

AngularJS evaluates the interpolation expression during rendering. A visible 1.8.2 confirms the downloaded framework matches the pinned script URL.

The first style rules give the page a consistent box model. They also establish the board's background palette.

  • Switch back to Visual Studio Code.
  • Select styles.css in the Explorer sidebar.
  • Add the page-level styles by pasting the code below:
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: #eef2f7;
  color: #172033;
  font-family: Arial, sans-serif;
}

What do these styles change?

  • The universal selector makes element dimensions include their borders plus padding.
  • The body rule removes the default margin.
  • The remaining body properties establish the page height plus its base colors.
  • Save styles.css by pressing Ctrl+S.
  • Switch back to Microsoft Edge.
  • Refresh the page by pressing Ctrl+R.

You should see a pale background with dark text. The default white page margin is gone.

Background still white?

Confirm styles.css is saved beside index.html. Check that the stylesheet link uses the same file name.

Ask for help with the stylesheet link: help me find why styles.css is not changing my local page.

The shell keeps the board readable on wide screens. Its automatic side margins center the content.

  • Return to styles.css in Visual Studio Code.
  • Add the shell rule below the body rule by pasting:
.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0;
}

What does the shell control?

The width leaves space beside the content on smaller screens. The maximum width prevents the board from stretching too far on larger displays.

  • Save styles.css.
  • Switch back to Microsoft Edge.
  • Refresh the page by pressing Ctrl+R.

You should see the heading centered inside a wide content area with space above it.

Content still touches the edge?

Check that the selector begins with .shell. Confirm the component template still uses class="shell" on the main element.

Ask for help with the shell layout: help me match the shell class in my CSS and component template.

The hero rules separate the framework check from the page edge. They also give the board title a responsive size.

  • Return to styles.css in Visual Studio Code.
  • Add the hero rules below the shell rule by pasting:
.hero {
  margin-bottom: 24px;
}

.hero h1 {
  margin: 6px 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

What do the hero rules control?

The header gains space beneath it. The title scales within fixed minimum plus maximum sizes.

  • Save styles.css.
  • Switch back to Microsoft Edge.
  • Refresh the page by pressing Ctrl+R.

You should see a larger board title with a small gap beneath the framework version.

Title size unchanged?

Confirm the selector contains a space between .hero and h1. Check that the heading remains inside the hero element.

Ask for help with the title selector: help me find why my hero heading styles are not applying.

The eyebrow style turns the version into a clear framework label. Its uppercase treatment separates the runtime check from the app title.

  • Return to styles.css in Visual Studio Code.
  • Add the eyebrow rule below the hero rules by pasting:
.eyebrow {
  margin: 0;
  color: #3158c9;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

What does the eyebrow rule change?

The framework label becomes blue plus bold. Letter spacing makes the uppercase text easier to distinguish from the title.

  • Save styles.css.

Before the final refresh, which part of the page do you expect to change most?

  • Switch back to Microsoft Edge.
  • Refresh the page by pressing Ctrl+R.

You should see a blue uppercase label reading AngularJS 1.8.2 above the large Legacy Help Desk Board heading.

You have proved the complete bootstrap path. The local page loads AngularJS 1.8.2, starts ticketDesk, then renders ticketBoard.

Version label not styled?

Confirm the CSS selector is .eyebrow without extra characters. Check that the version paragraph still uses class="eyebrow".

Ask for help with the version label: help me compare the eyebrow CSS with my AngularJS component template.

✔️ Awesome, I've got everything!

Your three files are saved. Leave index.html open in Microsoft Edge for the next step.

ⓧ I'd like to double check the full code

Compare your complete index.html with this reference.

<!doctype html>
<html lang="en" ng-app="ticketDesk">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Legacy Help Desk Board</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <ticket-board></ticket-board>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  <script src="app.js"></script>
</body>
</html>

What should match?

This file names the AngularJS module. It also preserves the required script order.

Compare your complete app.js with this reference.

(function () {
  'use strict';

  angular
    .module('ticketDesk', [])
    .component('ticketBoard', {
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
          '</header>',
        '</main>'
      ].join(''),
      controller: TicketBoardController
    });

  function TicketBoardController() {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
  }
}());

What should match?

This file registers the component. Its controller exposes the exact runtime version to the template.

Compare your complete styles.css with this reference.

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: #eef2f7;
  color: #172033;
  font-family: Arial, sans-serif;
}

.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0;
}

.hero {
  margin-bottom: 24px;
}

.hero h1 {
  margin: 6px 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

.eyebrow {
  margin: 0;
  color: #3158c9;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

What should match?

This file styles the page shell. It also makes the framework version visually distinct.

Your browser bootstrap is proven. Next, you turn this framework check into a live ticket composer.

Build a Live Ticket Composer

Your browser now proves AngularJS 1.8.2 is running. The next challenge is turning that framework check into a form that responds while you type.

The composer uses two-way data binding to synchronize the issue field with component state. This gives you immediate feedback without manual DOM updates.

In this step, get ready to:
  • Add a ticket model to the component controller.
  • Bind the issue field and priority selector to that model.
  • Show live input and form validation in the browser.
Add the form model

The component needs one object to hold the ticket being composed. Keeping this state on $ctrl makes its owner clear inside the component.

  • Select app.js inside the angularjs-help-desk folder in Visual Studio Code.
  • Find the vm.frameworkVersion = angular.version.full; line inside TicketBoardController.
  • Add the ticket model directly below that line by using this code:
    vm.newTicket = { title: '', priority: 'medium' };

What does this model hold?

  • The title property holds the issue text entered by the learner.
  • The priority property starts at medium so the selector always has an initial value.
Bind the form controls

The ngModel directive connects a form control to a model property. The issue input and live preview can now read the same newTicket.title value.

  • Select the existing template: [ array in app.js through its closing ].join(''), line.
  • Replace the selected template with this composer markup:
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
            '<p>Create, triage, and resolve support tickets without touching the DOM by hand.</p>',
          '</header>',
          '<section class="card">',
            '<h2>Create a ticket</h2>',
            '<form name="$ctrl.ticketForm" novalidate>',
              '<label for="title">Issue title</label>',
              '<input id="title" name="title" type="text" ng-model="$ctrl.newTicket.title" ng-maxlength="60" required placeholder="Example: VPN access is blocked">',
              '<p class="hint" ng-if="$ctrl.newTicket.title">Live preview: {{$ctrl.newTicket.title}}</p>',
              '<label for="priority">Priority</label>',
              '<select id="priority" ng-model="$ctrl.newTicket.priority">',
                '<option value="low">Low</option>',
                '<option value="medium">Medium</option>',
                '<option value="high">High</option>',
              '</select>',
            '</form>',
          '</section>',
        '</main>'
      ].join(''),

How does the binding work?

  • The named form is published as $ctrl.ticketForm so the component can read its validation state.
  • The issue input writes each change to $ctrl.newTicket.title.
  • The preview reads that same property through interpolation.
  • The priority selector writes its selected value to $ctrl.newTicket.priority.
  • Save app.js.
  • Switch back to the browser from earlier.
  • Refresh index.html.
  • Type Printer offline into the Issue title field.

You should see Live preview: Printer offline beneath the field. The priority selector should show Medium.

Preview not updating?

Confirm the input uses ng-model="$ctrl.newTicket.title". Check that the preview reads {{$ctrl.newTicket.title}}.

Check the browser developer console for a template syntax error if the entire component disappears.

Help me debug the live AngularJS ticket preview.

The controls now work. A small set of styles makes the composer easier to scan while you test its states.

  • Switch back to styles.css in Visual Studio Code.
  • Insert this shared font rule below the closing brace of the body rule:
button,
input,
select {
  font: inherit;
}

Why share the font?

Browser form controls can use their own default fonts. This rule makes the input, selector, and button match the rest of the board.

  • Save styles.css.
  • Refresh the browser.

The issue field and priority selector should now use the same font as the page.

  • Append the card layout rules to the end of styles.css by using this code:
.card {
  padding: 24px;
  border: 1px solid #d8deea;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
}

.card h2 {
  margin-top: 0;
}

form {
  display: grid;
  gap: 10px;
}

label {
  font-weight: 700;
}

What do these layout rules do?

  • The .card rule places the composer on a white panel with spacing and a visible boundary.
  • The form rule arranges each control in a vertical grid.
  • The label rule makes each field name easier to identify.
  • Save styles.css.
  • Refresh the browser.

You should see the composer inside a white card. Its labels and controls should form a spaced vertical column.

  • Append the control styles beneath the label rule by using this code:
input,
select,
button {
  min-height: 42px;
  border: 1px solid #aeb8ca;
  border-radius: 8px;
  padding: 9px 12px;
}

button {
  cursor: pointer;
  background: #ffffff;
}

.primary {
  border-color: #3158c9;
  background: #3158c9;
  color: #ffffff;
}

What changes in the composer?

The shared control rule gives every field a consistent height and border. The .primary rule gives the submission button a clear visual role.

  • Save styles.css.
  • Refresh the browser.

The input and selector should now have rounded borders. The composer is ready for visible validation feedback.

Surface validation state

A named AngularJS form tracks whether each control is valid or touched. The composer can use those states to show an error after someone interacts with invalid input.

  • Switch back to app.js.
  • Select the current template: [ array through its closing ].join(''), line.
  • Replace the selected template with this validated version:
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
            '<p>Create, triage, and resolve support tickets without touching the DOM by hand.</p>',
          '</header>',
          '<section class="card">',
            '<h2>Create a ticket</h2>',
            '<form name="$ctrl.ticketForm" novalidate>',
              '<label for="title">Issue title</label>',
              '<input id="title" name="title" type="text" ng-model="$ctrl.newTicket.title" ng-maxlength="60" required placeholder="Example: VPN access is blocked">',
              '<p class="hint" ng-if="$ctrl.newTicket.title">Live preview: {{$ctrl.newTicket.title}}</p>',
              '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.required">Enter a ticket title.</p>',
              '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.maxlength">Use no more than 60 characters.</p>',
              '<label for="priority">Priority</label>',
              '<select id="priority" ng-model="$ctrl.newTicket.priority">',
                '<option value="low">Low</option>',
                '<option value="medium">Medium</option>',
                '<option value="high">High</option>',
              '</select>',
              '<button class="primary" type="submit" ng-disabled="$ctrl.ticketForm.$invalid">Add ticket</button>',
            '</form>',
          '</section>',
        '</main>'
      ].join(''),

How does validation respond?

  • The required attribute marks an empty title as invalid.
  • The ng-maxlength="60" directive marks a title over 60 characters as invalid.
  • The $touched state delays each error message until the field has been visited.
  • The $error object identifies which validation rule failed.
  • The ng-disabled directive disables the button while $ctrl.ticketForm.$invalid is true.
  • Save app.js.
  • Switch back to the browser.
  • Refresh the page.
  • Select the empty Issue title field.
  • Select the Priority field to move focus away from the issue field.

You should see Enter a ticket title. beneath the empty field. The Add ticket button should remain disabled.

  • Switch back to styles.css.
  • Insert the touched-input selector below the shared input control rule.
  • Insert the disabled-button selector below the button rule by using these styles:
input.ng-invalid.ng-touched {
  border-color: #b42318;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

How do the invalid states look?

A touched invalid input receives a red border. A disabled button becomes faded and loses its active pointer cursor.

  • Save styles.css.
  • Refresh the browser.
  • Select the empty issue field.
  • Select the priority field.

The empty issue field should now have a red border. The disabled button should look faded.

  • Append the preview and error colors to the end of styles.css by using this code:
.hint {
  margin: 0;
  color: #3158c9;
}

.error {
  margin: 0;
  color: #b42318;
}

Why use separate feedback colors?

The blue preview shows valid live feedback. The red message draws attention to input that needs correction.

  • Save styles.css.
  • Refresh the browser.
  • Enter Printer offline in the finance office after the morning restart into the issue field.

You should see Use no more than 60 characters. in red. The Add ticket button should remain disabled.

Before the final check, where do you expect the words from the issue field to appear as you type?

  • Select the current text inside the Issue title field.
  • Type Printer offline.

You should see Live preview: Printer offline immediately. The validation message should disappear and the Add ticket button should become available.

Validation state not changing?

Confirm the form is named $ctrl.ticketForm. Check that the issue input is named title.

Make sure the error conditions reference $ctrl.ticketForm.title with the same capitalization.

Help me debug the AngularJS ticket form validation.

✔️ Awesome, I've got everything!

Your composer now synchronizes the issue field with component state. Save app.js and styles.css before continuing.

ⓧ I'd like to double check the full code

Compare your complete app.js with this reference.

(function () {
  'use strict';

  angular
    .module('ticketDesk', [])
    .component('ticketBoard', {
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
            '<p>Create, triage, and resolve support tickets without touching the DOM by hand.</p>',
          '</header>',
          '<section class="card">',
            '<h2>Create a ticket</h2>',
            '<form name="$ctrl.ticketForm" novalidate>',
              '<label for="title">Issue title</label>',
              '<input id="title" name="title" type="text" ng-model="$ctrl.newTicket.title" ng-maxlength="60" required placeholder="Example: VPN access is blocked">',
              '<p class="hint" ng-if="$ctrl.newTicket.title">Live preview: {{$ctrl.newTicket.title}}</p>',
              '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.required">Enter a ticket title.</p>',
              '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.maxlength">Use no more than 60 characters.</p>',
              '<label for="priority">Priority</label>',
              '<select id="priority" ng-model="$ctrl.newTicket.priority">',
                '<option value="low">Low</option>',
                '<option value="medium">Medium</option>',
                '<option value="high">High</option>',
              '</select>',
              '<button class="primary" type="submit" ng-disabled="$ctrl.ticketForm.$invalid">Add ticket</button>',
            '</form>',
          '</section>',
        '</main>'
      ].join(''),
      controller: TicketBoardController
    });

  function TicketBoardController() {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
    vm.newTicket = { title: '', priority: 'medium' };
  }
}());

This file defines the ticket model and the complete validated component template.

Confirm your unchanged index.html still matches this reference.

<!doctype html>
<html lang="en" ng-app="ticketDesk">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Legacy Help Desk Board</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <ticket-board></ticket-board>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  <script src="app.js"></script>
</body>
</html>

This file still bootstraps the app and loads the component code after AngularJS.

Compare your complete styles.css with this reference.

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: #eef2f7;
  color: #172033;
  font-family: Arial, sans-serif;
}

button,
input,
select {
  font: inherit;
}

.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0;
}

.hero {
  margin-bottom: 24px;
}

.hero h1 {
  margin: 6px 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

.eyebrow {
  margin: 0;
  color: #3158c9;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.card {
  padding: 24px;
  border: 1px solid #d8deea;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
}

.card h2 {
  margin-top: 0;
}

form {
  display: grid;
  gap: 10px;
}

label {
  font-weight: 700;
}

input,
select,
button {
  min-height: 42px;
  border: 1px solid #aeb8ca;
  border-radius: 8px;
  padding: 9px 12px;
}

input.ng-invalid.ng-touched {
  border-color: #b42318;
}

button {
  cursor: pointer;
  background: #ffffff;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.primary {
  border-color: #3158c9;
  background: #3158c9;
  color: #ffffff;
}

.hint {
  margin: 0;
  color: #3158c9;
}

.error {
  margin: 0;
  color: #b42318;
}

This file supplies the complete composer layout and validation styling.

That is a working two-way-bound composer. Next, you will connect it to a ticket list and expose a real identity problem with duplicate titles.

Expose the Naive List Limitation

Your live composer already proves that AngularJS can synchronize form input with component state. The next risk appears when several submitted values need separate rows.

A plain array of title strings looks sufficient for a ticket queue. This step deliberately tests its list identity when two titles are identical.

In this step, get ready to:
  • Store submitted ticket titles as primitive strings.
  • Render each title with an AngularJS repeater.
  • Submit one title twice to expose the identity collision.
Add primitive ticket state

The form currently validates input without storing a submission. An ng-submit handler connects the valid form to a controller method.

  • In app.js, find the form opening tag that contains name="$ctrl.ticketForm".
  • Replace that opening tag with the code below:
              '<form name="$ctrl.ticketForm" ng-submit="$ctrl.addTicket($ctrl.ticketForm)" novalidate>',

How Does Submission Reach the Controller?

The ng-submit directive evaluates $ctrl.addTicket($ctrl.ticketForm) after the form is submitted. Passing $ctrl.ticketForm gives the controller access to its validation state.

The controller needs a collection for submitted titles. Its handler can reject invalid input before adding a cleaned string to that collection.

  • In TicketBoardController(), locate the line that initializes vm.newTicket.
  • Paste this code directly below that line:
    vm.tickets = [];

    vm.addTicket = function (form) {
      var cleanTitle = vm.newTicket.title.trim();

      if (form.$invalid || !cleanTitle) {
        return;
      }

      vm.tickets.push(cleanTitle);
      vm.newTicket = { title: '', priority: 'medium' };
    };

What Does the Handler Do?

  • The vm.tickets array starts empty.
  • The cleanTitle variable holds the title after surrounding spaces are removed.
  • The form.$invalid check stops invalid submissions.
  • The vm.tickets.push(cleanTitle) call stores the title as a primitive string.
  • The new vm.newTicket object clears the controls after a successful submission.
  • Save app.js.
  • Switch back to the browser from earlier.
  • Refresh index.html.
  • Type Network drive missing into the Issue title field.
  • Click Add ticket.

The issue field should clear after submission. That visible reset proves the new handler accepted the valid title.

Does the Issue Title Stay Put?

Check that the form opening tag includes ng-submit="$ctrl.addTicket($ctrl.ticketForm)". Confirm that vm.addTicket sits inside TicketBoardController().

Ask for help if the valid form still does not clear: help me debug why my AngularJS ng-submit handler is not clearing newTicket

Render primitive ticket titles

The stored strings need a visible queue. A simple ng-repeat can turn each array value into one list item.

  • In the component template inside app.js, find the <section class="card"> line immediately below </header>.
  • Replace that line with the code below:
          '<section class="grid">',
            '<section class="card">',

Why Add the Grid Wrapper?

The grid section groups the composer card with the queue card. This shared wrapper lets the stylesheet arrange both areas together.

  • Find the </section> line immediately below the form's closing </form> line.
  • Replace everything from that closing section through the existing </main> line with the code below:
              '</form>',
            '</section>',
            '<section class="card">',
              '<h2>Ticket queue</h2>',
              '<ul class="ticket-list">',
                '<li ng-repeat="ticket in $ctrl.tickets">{{ticket}}</li>',
              '</ul>',
            '</section>',
          '</section>',
        '</main>'

How Does the Queue Render?

  • The ng-repeat="ticket in $ctrl.tickets" expression creates one list item for each stored string.
  • The {{ticket}} expression displays the current string.
  • Each repeated value currently depends on AngularJS's default identity behavior.
  • Save app.js.
  • Refresh index.html in your browser.
  • Type Printer offline into the Issue title field.
  • Click Add ticket.

You should see Printer offline inside the new Ticket queue section. Your form now feeds a visible repeated list.

The two cards need a shared layout so the new queue stays readable beside the composer. The grid adapts its columns to the available browser width.

  • In styles.css, locate the closing brace for the .eyebrow rule.
  • Add this grid rule directly below it:
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
  align-items: start;
}

What Does the Grid Rule Do?

The browser creates as many card columns as fit above the 300px minimum. The cards stack when the available width becomes smaller.

  • Save styles.css.
  • Refresh index.html in your browser.

You should see the composer and ticket queue in separate cards. A wide browser window places the cards in columns.

Are the Cards Still in One Column?

Make the browser window wider before checking the layout. Confirm that the component template uses class="grid" around both card sections.

Ask for help if the grid rule is not taking effect: help me debug why the two AngularJS help desk cards are not using my CSS grid

The queue still uses the browser's default list marker. A small list rule gives each repeated title consistent spacing.

  • Add this rule at the bottom of styles.css:
.ticket-list {
  display: grid;
  gap: 12px;
  margin: 20px 0 0;
  padding: 0;
  list-style: none;
}

What Does the List Rule Do?

The ticket-list rule removes the default markers. Its grid gap separates repeated ticket titles.

  • Save styles.css.
  • Refresh index.html in your browser.
  • Type Printer offline into the Issue title field.
  • Click Add ticket.

You should see a clean ticket row without a bullet marker. The queue now provides a visible surface for the identity test.

Does the Ticket Still Have a Marker?

Check that the list uses class="ticket-list". Confirm that the .ticket-list rule contains list-style: none.

Ask for help if the list styling is missing: help me find why the ticket-list CSS is not removing the list marker

✔️ Awesome, I've got everything!

Great. Save app.js and styles.css before testing the duplicate title.

ⓧ I'd like to double check the full code

Compare your three project files with the complete versions below.

(function () {
  'use strict';

  angular
    .module('ticketDesk', [])
    .component('ticketBoard', {
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
            '<p>Create, triage, and resolve support tickets without touching the DOM by hand.</p>',
          '</header>',
          '<section class="grid">',
            '<section class="card">',
              '<h2>Create a ticket</h2>',
              '<form name="$ctrl.ticketForm" ng-submit="$ctrl.addTicket($ctrl.ticketForm)" novalidate>',
                '<label for="title">Issue title</label>',
                '<input id="title" name="title" type="text" ng-model="$ctrl.newTicket.title" ng-maxlength="60" required placeholder="Example: VPN access is blocked">',
                '<p class="hint" ng-if="$ctrl.newTicket.title">Live preview: {{$ctrl.newTicket.title}}</p>',
                '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.required">Enter a ticket title.</p>',
                '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.maxlength">Use no more than 60 characters.</p>',
                '<label for="priority">Priority</label>',
                '<select id="priority" ng-model="$ctrl.newTicket.priority">',
                  '<option value="low">Low</option>',
                  '<option value="medium">Medium</option>',
                  '<option value="high">High</option>',
                '</select>',
                '<button class="primary" type="submit" ng-disabled="$ctrl.ticketForm.$invalid">Add ticket</button>',
              '</form>',
            '</section>',
            '<section class="card">',
              '<h2>Ticket queue</h2>',
              '<ul class="ticket-list">',
                '<li ng-repeat="ticket in $ctrl.tickets">{{ticket}}</li>',
              '</ul>',
            '</section>',
          '</section>',
        '</main>'
      ].join(''),
      controller: TicketBoardController
    });

  function TicketBoardController() {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
    vm.newTicket = { title: '', priority: 'medium' };
    vm.tickets = [];

    vm.addTicket = function (form) {
      var cleanTitle = vm.newTicket.title.trim();

      if (form.$invalid || !cleanTitle) {
        return;
      }

      vm.tickets.push(cleanTitle);
      vm.newTicket = { title: '', priority: 'medium' };
    };
  }
}());
<!doctype html>
<html lang="en" ng-app="ticketDesk">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Legacy Help Desk Board</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <ticket-board></ticket-board>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  <script src="app.js"></script>
</body>
</html>
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: #eef2f7;
  color: #172033;
  font-family: Arial, sans-serif;
}

button,
input,
select {
  font: inherit;
}

.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0;
}

.hero {
  margin-bottom: 24px;
}

.hero h1 {
  margin: 6px 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

.eyebrow {
  margin: 0;
  color: #3158c9;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
  align-items: start;
}

.card {
  padding: 24px;
  border: 1px solid #d8deea;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
}

.card h2 {
  margin-top: 0;
}

form {
  display: grid;
  gap: 10px;
}

label {
  font-weight: 700;
}

input,
select,
button {
  min-height: 42px;
  border: 1px solid #aeb8ca;
  border-radius: 8px;
  padding: 9px 12px;
}

input.ng-invalid.ng-touched {
  border-color: #b42318;
}

button {
  cursor: pointer;
  background: #ffffff;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.primary {
  border-color: #3158c9;
  background: #3158c9;
  color: #ffffff;
}

.hint {
  margin: 0;
  color: #3158c9;
}

.error {
  margin: 0;
  color: #b42318;
}

.ticket-list {
  display: grid;
  gap: 12px;
  margin: 20px 0 0;
  padding: 0;
  list-style: none;
}
Trigger the duplicate collision

Each primitive string currently acts as its own repeater identity. Two identical titles therefore produce the same identity key.

Before you test it, do you think two identical strings can become two separate ticket rows?

  • Switch back to the browser from earlier.
  • Refresh index.html.
  • Type VPN access is blocked into the Issue title field.
  • Click Add ticket.
  • Type VPN access is blocked into the Issue title field again.
  • Click Add ticket again.

You should see only one successful VPN access is blocked row. The second matching primitive cannot render as a distinct repeated item.

Why Does the Duplicate Fail?

The current repeater uses each primitive value as its identity. Matching strings create a duplicate key because the list has no separate ID for either ticket.

This is the intended limitation for this step. The collision proves that a visible title cannot safely serve as a ticket's identity.

You have exposed the weakness in the naive list. Next, you'll give every ticket a stable identity and move ticket state into a dedicated service.

Fix Ticket Identity with a Service

Your duplicate-title test exposed a real identity problem. AngularJS's repeater could not distinguish two matching primitive strings.

A factory service gives ticket state one owner. Stable numeric IDs let the repeater map every ticket to a separate row.

In this step, get ready to:
  • Move ticket state into a factory service.
  • Inject the shared ticket array into the board controller.
  • Track every repeated ticket by its stable ID.
Create the ticket store

The ticketStore factory owns the ticket array. Its nextId value increases after every addition.

Why Give State One Owner?

The component asks ticketStore for ticket data. Every future ticket action reaches the same array through the service.

This separation keeps storage logic outside the component template. The controller stays focused on connecting state to the interface.

  • Switch back to app.js in Visual Studio Code.
  • Locate the line immediately above function TicketBoardController().
  • Add the factory foundation by pasting this code there:
  function ticketStore() {
    var nextId = 3;
    var tickets = [
      { id: 1, title: 'Reset email password', priority: 'high', resolved: false },
      { id: 2, title: 'Install finance printer', priority: 'medium', resolved: true }
    ];
  }

What Does This Foundation Hold?

  • The tickets array starts with two ticket objects.
  • Each object carries an id value that stays independent from its title.
  • The nextId counter begins at 3 because the seeded tickets already use 1 and 2.
  • Find the final two lines of the new ticketStore function:
    ];
  }

Where Do the Methods Go?

The factory methods belong after the seeded array. They remain inside the ticketStore function.

  • Replace those two lines with this completed return block:
    ];

    return {
      all: function () {
        return tickets;
      },
      add: function (title, priority) {
        tickets.push({
          id: nextId,
          title: title,
          priority: priority,
          resolved: false
        });
        nextId += 1;
      },
      toggle: function (ticket) {
        ticket.resolved = !ticket.resolved;
      },
      remove: function (ticket) {
        var index = tickets.indexOf(ticket);
        if (index !== -1) {
          tickets.splice(index, 1);
        }
      }
    };
  }

What Do These Methods Do?

  • The all method returns the shared ticket array.
  • The add method creates an object with a stable ID.
  • The toggle method flips the ticket's resolved state.
  • The remove method removes the matching object from the array.
  • Save app.js.
  • Return to the help desk board in your browser.
  • Refresh the page.

You'll see the help desk heading with the validated composer. This confirms the new factory function parses successfully.

  • Return to the AngularJS module chain near the top of app.js.
  • Add this registration line directly below .module('ticketDesk', []):
    .factory('ticketStore', ticketStore)

What Does Registration Do?

The .factory() call registers ticketStore with the module. AngularJS can now provide the returned service instance to another registered component.

  • Save app.js.
  • Refresh the browser.

You'll see the board load normally. The registered factory is ready for the controller to request it.

Did the Board Stop Loading?

Check that .factory('ticketStore', ticketStore) appears between the module declaration and the component registration. Confirm that the ticketStore function sits outside TicketBoardController.

Ask for help with the factory registration.

Inject the shared ticket state

The controller needs the service instance before it can use the shared array. AngularJS handles this connection through dependency injection.

  • Find this controller opening in app.js:
  function TicketBoardController() {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
    vm.newTicket = { title: '', priority: 'medium' };
    vm.tickets = [];

What Needs to Change?

The controller currently creates its own empty array. It also accepts no injected service.

  • Replace that controller opening with this injected version:
  TicketBoardController.$inject = ['ticketStore'];

  function TicketBoardController(ticketStore) {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
    vm.newTicket = { title: '', priority: 'medium' };
    vm.tickets = ticketStore.all();

How Does Injection Connect the State?

  • The $inject array names the service that the controller requires.
  • The ticketStore parameter receives that service instance.
  • The ticketStore.all() call gives vm.tickets the service-owned array.
  • Save app.js.
  • Refresh the browser.

You'll see two seeded entries in the ticket queue. Their object data appears because the existing repeater still renders each whole ticket value.

  • Find this line inside vm.addTicket:
      vm.tickets.push(cleanTitle);

What Does the Old Line Do?

The old line pushes a primitive title directly into the controller's array. That recreates the duplicate identity problem.

  • Replace the old push with this service call:
      ticketStore.add(cleanTitle, vm.newTicket.priority);

What Does the Service Receive?

The controller passes the cleaned title with the selected priority. The service adds the ID with the initial resolved state.

  • Save app.js.
  • Refresh the browser.
  • Enter Printer offline in the Issue title field.
  • Click Add ticket.

You'll see a third object entry in the queue. Its new numeric ID confirms that ticketStore.add() handled the submission.

Don't See a Third Entry?

Confirm that TicketBoardController receives ticketStore. Check that vm.addTicket calls ticketStore.add(cleanTitle, vm.newTicket.priority).

Ask for help tracing the injected service.

Track each ticket by ID

Each ticket now has a unique id property. The repeater can use that property as its stable identity key.

  • Find the current list item inside the ticket queue:
                '<li ng-repeat="ticket in $ctrl.tickets">{{ticket}}</li>',

Why Does the Repeater Still Need Work?

The repeater still relies on its default identity behavior. Its interpolation also renders the whole ticket object.

  • Replace the current list item with this ID-tracked version:
                '<li ng-repeat="ticket in $ctrl.tickets track by ticket.id">{{ticket.title}}</li>',

How Does Stable Tracking Work?

  • The track by ticket.id clause gives every repeated row a unique identity.
  • The {{ticket.title}} expression displays the readable issue title.
  • Matching titles remain separate because their IDs differ.

Before you test the fix, do you think two matching titles now produce one row or two rows?

  • Save app.js.
  • Refresh the browser.
  • Enter VPN access is blocked in the Issue title field.
  • Click Add ticket.
  • Enter VPN access is blocked in the Issue title field again.
  • Click Add ticket again.

You'll see VPN access is blocked on two separate rows. Each ticket object has its own ID.

That's the identity bug fixed. Your board can now hold repeated titles as separate tickets.

✔️ Awesome, I've got everything!

Great. Double check that you saved app.js after confirming both duplicate titles appear.

ⓧ I'd like to double check the full code

Compare your app.js with the complete version below.

(function () {
  'use strict';

  angular
    .module('ticketDesk', [])
    .factory('ticketStore', ticketStore)
    .component('ticketBoard', {
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
            '<p>Create, triage, and resolve support tickets without touching the DOM by hand.</p>',
          '</header>',
          '<section class="grid">',
            '<section class="card">',
              '<h2>Create a ticket</h2>',
              '<form name="$ctrl.ticketForm" ng-submit="$ctrl.addTicket($ctrl.ticketForm)" novalidate>',
                '<label for="title">Issue title</label>',
                '<input id="title" name="title" type="text" ng-model="$ctrl.newTicket.title" ng-maxlength="60" required placeholder="Example: VPN access is blocked">',
                '<p class="hint" ng-if="$ctrl.newTicket.title">Live preview: {{$ctrl.newTicket.title}}</p>',
                '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.required">Enter a ticket title.</p>',
                '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.maxlength">Use no more than 60 characters.</p>',
                '<label for="priority">Priority</label>',
                '<select id="priority" ng-model="$ctrl.newTicket.priority">',
                  '<option value="low">Low</option>',
                  '<option value="medium">Medium</option>',
                  '<option value="high">High</option>',
                '</select>',
                '<button class="primary" type="submit" ng-disabled="$ctrl.ticketForm.$invalid">Add ticket</button>',
              '</form>',
            '</section>',
            '<section class="card">',
              '<h2>Ticket queue</h2>',
              '<ul class="ticket-list">',
                '<li ng-repeat="ticket in $ctrl.tickets track by ticket.id">{{ticket.title}}</li>',
              '</ul>',
            '</section>',
          '</section>',
        '</main>'
      ].join(''),
      controller: TicketBoardController
    });

  function ticketStore() {
    var nextId = 3;
    var tickets = [
      { id: 1, title: 'Reset email password', priority: 'high', resolved: false },
      { id: 2, title: 'Install finance printer', priority: 'medium', resolved: true }
    ];

    return {
      all: function () {
        return tickets;
      },
      add: function (title, priority) {
        tickets.push({
          id: nextId,
          title: title,
          priority: priority,
          resolved: false
        });
        nextId += 1;
      },
      toggle: function (ticket) {
        ticket.resolved = !ticket.resolved;
      },
      remove: function (ticket) {
        var index = tickets.indexOf(ticket);
        if (index !== -1) {
          tickets.splice(index, 1);
        }
      }
    };
  }

  TicketBoardController.$inject = ['ticketStore'];

  function TicketBoardController(ticketStore) {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
    vm.newTicket = { title: '', priority: 'medium' };
    vm.tickets = ticketStore.all();

    vm.addTicket = function (form) {
      var cleanTitle = vm.newTicket.title.trim();

      if (form.$invalid || !cleanTitle) {
        return;
      }

      ticketStore.add(cleanTitle, vm.newTicket.priority);
      vm.newTicket = { title: '', priority: 'medium' };
    };
  }
}());

Your file should contain the registered factory before the component. The injected controller should use the shared array from ticketStore.all().

Does the Second Title Still Fail?

Confirm that the repeater ends with track by ticket.id. Check that ticketStore.add() increments nextId after pushing each object.

Ask for help checking ticket identity.

Your ticket queue now has stable identity with service-owned state. Next, you'll add the triage controls that turn these rows into a working help desk board.

Add Triage, Counts, and Actions

Your board now keeps stable ticket objects in the injected ticketStore service. That identity fix lets AngularJS update individual tickets even when their titles match.

The board still needs a practical triage workflow. In this step, you will add ticket actions, status filtering, live counts, empty-state feedback, and form resets.

In this step, get ready to:
  • Add controls for resolving, reopening, and deleting tickets.
  • Filter the queue by open or resolved status.
  • Display live counts, empty-state feedback, and a clean form reset.
Wire ticket actions

Each row needs controls that delegate changes to ticketStore. The component controller also supplies labels that reflect each ticket's current state.

  • Return to app.js in Visual Studio Code.
  • Add these methods below vm.addTicket inside TicketBoardController:
    vm.toggleTicket = function (ticket) {
      ticketStore.toggle(ticket);
    };

    vm.removeTicket = function (ticket) {
      ticketStore.remove(ticket);
    };

    vm.statusLabel = function (ticket) {
      return ticket.resolved ? 'Resolved' : 'Open';
    };

    vm.toggleLabel = function (ticket) {
      return ticket.resolved ? 'Reopen' : 'Resolve';
    };

How Do These Methods Work?

  • The action methods pass the selected ticket to the service that owns the shared array.
  • The label methods derive visible text from the ticket's resolved value.
  • The same button can therefore display Resolve or Reopen as the state changes.
  • Find the current repeated row in the ticketBoard template.
  • Use this line to confirm that you found the correct row:
                '<li ng-repeat="ticket in $ctrl.tickets track by ticket.id">{{ticket.title}}</li>',

What Are You Replacing?

The current row displays only a title. The replacement keeps the same stable ID strategy while exposing the ticket's priority, status, and actions.

  • Replace that line with this complete ticket row:
                '<li class="ticket" ng-class="{isResolved: ticket.resolved}" ng-repeat="ticket in $ctrl.tickets | filter:$ctrl.matchesStatus track by ticket.id">',
                  '<div class="ticket-copy">',
                    '<strong>{{ticket.title}}</strong>',
                    '<div class="ticket-meta">',
                      '<span class="priority priority-{{ticket.priority}}">{{ticket.priority}}</span>',
                      '<span>{{$ctrl.statusLabel(ticket)}}</span>',
                    '</div>',
                  '</div>',
                  '<div class="ticket-actions">',
                    '<button type="button" ng-click="$ctrl.toggleTicket(ticket)">{{$ctrl.toggleLabel(ticket)}}</button>',
                    '<button class="danger" type="button" ng-click="$ctrl.removeTicket(ticket)">Delete</button>',
                  '</div>',
                '</li>',

What Does This Row Do?

  • The ngClass expression adds isResolved when the ticket is resolved.
  • Each ngClick expression sends the row's ticket object to its controller method.
  • The priority class includes the current value so CSS can assign a distinct badge colour.
  • Save app.js.
  • Refresh the board in Microsoft Edge or Google Chrome.

You will see each seeded ticket with its priority, current status, and two action buttons.

  • Select Resolve on the open ticket.

The ticket changes to Resolved. Its action button changes to Reopen.

  • Add the row layout styles below .ticket-list in styles.css:
.ticket {
  display: flex;
  gap: 12px;
  justify-content: space-between;
  align-items: center;
  padding: 14px;
  border: 1px solid #d8deea;
  border-radius: 10px;
}

.ticket.isResolved strong {
  color: #6b7280;
  text-decoration: line-through;
}

.ticket-copy {
  display: grid;
  gap: 8px;
}

What Do These Styles Change?

Each ticket becomes a bordered row with space for its details and controls. Resolved titles become muted with a line through the text.

  • Save styles.css.
  • Refresh the board in your browser.

You will see each ticket inside a rounded row. Resolved ticket titles have a visible line through them.

  • Add the metadata layout and badge foundation below the new row styles:
.ticket-meta,
.ticket-actions {
  display: flex;
  gap: 8px;
  align-items: center;
}

.priority {
  border-radius: 999px;
  padding: 3px 8px;
  color: #ffffff;
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
}

How Is Ticket Metadata Arranged?

The metadata and buttons use compact horizontal groups. The shared .priority class gives every priority value the same badge shape.

  • Add the priority colours and delete-button style below .priority:
.priority-low {
  background: #367a4a;
}

.priority-medium {
  background: #a15c00;
}

.priority-high {
  background: #b42318;
}

.danger {
  border-color: #b42318;
  color: #b42318;
}

How Do The Colours Help?

Each priority gets a consistent colour that makes scanning the queue faster. The delete button uses the same red family to signal a destructive action.

  • Save styles.css.
  • Refresh the board in your browser.

You will see coloured priority badges beside each status. The delete buttons now have red borders and text.

Actions Not Updating A Ticket?

Check that each button passes ticket to the matching controller method. Confirm that the methods call ticketStore.toggle and ticketStore.remove.

Ask for help with the exact row and controller methods if the state still stays unchanged. Help me debug my AngularJS ticket action methods.

Filter the board

A growing queue needs a way to focus on one ticket state. The built-in filter can call a predicate that keeps only tickets matching the selected status.

  • Replace the Ticket queue heading line with this board heading and status selector:
              '<div class="board-heading">',
                '<div>',
                  '<h2>Ticket queue</h2>',
                '</div>',
                '<label class="filter" for="status">',
                  '<span>Show</span>',
                  '<select id="status" ng-model="$ctrl.status">',
                    '<option value="all">All</option>',
                    '<option value="open">Open</option>',
                    '<option value="resolved">Resolved</option>',
                  '</select>',
                '</label>',
              '</div>',

What Does The Selector Control?

The select stores its current value in $ctrl.status. Its three values map directly to the views the predicate understands.

  • Save app.js.
  • Refresh the board in your browser.

You will see a Show selector beside the ticket queue heading.

  • Add the default status below vm.newTicket in TicketBoardController:
    vm.status = 'all';

Why Set A Default Status?

The initial value selects All when the component loads. Every ticket remains visible until the learner chooses a narrower view.

  • Save app.js.
  • Refresh the board in your browser.

The status selector now displays All as its selected value.

  • Add the filtering predicate below vm.removeTicket in TicketBoardController:
    vm.matchesStatus = function (ticket) {
      if (vm.status === 'open') {
        return !ticket.resolved;
      }

      if (vm.status === 'resolved') {
        return ticket.resolved;
      }

      return true;
    };

How Does The Predicate Decide?

  • The open branch keeps tickets whose resolved value is false.
  • The resolved branch keeps tickets whose resolved value is true.
  • The final return keeps every ticket for the all view.
  • Find the repeated ticket-row line in the component template.
  • Confirm that it currently contains this repeater:
                '<li class="ticket" ng-class="{isResolved: ticket.resolved}" ng-repeat="ticket in $ctrl.tickets track by ticket.id">',

What Is Missing From The Repeater?

The current repeater still reads the complete array. It needs to pass that array through $ctrl.matchesStatus before AngularJS applies the stable ID.

  • Replace the current repeater line with this filtered version:
                '<li class="ticket" ng-class="{isResolved: ticket.resolved}" ng-repeat="ticket in $ctrl.tickets | filter:$ctrl.matchesStatus track by ticket.id">',

Why Does Track By Come Last?

AngularJS filters the collection first. It then uses ticket.id to associate each remaining object with the correct row.

  • Save app.js.
  • Refresh the board in your browser.
  • Select Open in the Show selector.

You will see only unresolved tickets. Selecting Resolved shows only resolved tickets.

  • Find this label rule in styles.css:
label {
  font-weight: 700;
}

Why Update The Label Rule?

The selector places its visible label inside a span. The updated selector gives that span the same weight as the existing form labels.

  • Replace the label rule with this shared rule:
label,
.filter span {
  font-weight: 700;
}

What Does The Shared Rule Cover?

The rule keeps every existing label bold. It also styles the new Show span without changing the template structure.

  • Add the board heading styles above .ticket-list:
.board-heading {
  display: flex;
  gap: 16px;
  justify-content: space-between;
  align-items: end;
}

.stats {
  margin: 4px 0 0;
  color: #526079;
}

.filter {
  display: grid;
  gap: 4px;
}

How Is The Queue Header Styled?

The heading and filter sit at opposite sides of the card. The filter stacks its label above the select so both remain easy to scan.

  • Save styles.css.
  • Refresh the board in your browser.

The status selector now sits neatly beside the queue heading. Changing its value immediately changes the visible rows.

Filter Shows The Wrong Tickets?

Confirm that the option values use lowercase all, open, and resolved. Check that the repeater places the filter before track by ticket.id.

Use the current selector value when asking for more help. Help me debug my AngularJS status filter.

Finish the dashboard feedback

A triage board should explain its state at a glance. Live counts, an empty message, and a clean form reset make every action visible.

  • Add the count methods below vm.matchesStatus in TicketBoardController:
    vm.openCount = function () {
      return vm.tickets.filter(function (ticket) {
        return !ticket.resolved;
      }).length;
    };

    vm.resolvedCount = function () {
      return vm.tickets.filter(function (ticket) {
        return ticket.resolved;
      }).length;
    };

How Do The Counts Stay Live?

Each method filters the shared ticket array by resolved state. AngularJS reevaluates the expressions after ticket actions update that state.

  • Add this statistics line directly below the Ticket queue heading:
                  '<p class="stats">Total {{$ctrl.tickets.length}} | Open {{$ctrl.openCount()}} | Resolved {{$ctrl.resolvedCount()}}</p>',

What Does The Summary Show?

The total reads the shared array length. The other values come from the two state-specific count methods.

  • Save app.js.
  • Refresh the board in your browser.

You will see total, open, and resolved counts below the queue heading. Resolving or reopening a ticket updates the counts immediately.

  • Add this empty-state message between the board heading and ticket-list:
              '<p class="empty" ng-if="($ctrl.tickets | filter:$ctrl.matchesStatus).length === 0">No tickets in this view.</p>',

When Does The Message Appear?

The ngIf expression applies the same status predicate as the repeater. AngularJS adds the message only when the filtered result contains no tickets.

  • Add the empty-state styles below .danger in styles.css:
.empty {
  padding: 18px;
  border-radius: 10px;
  background: #f7f8fb;
  color: #526079;
  text-align: center;
}

Why Style The Empty State?

The soft background separates the feedback from the controls. Learners can tell that the filter worked even when no ticket rows remain.

  • Save app.js.
  • Save styles.css.
  • Refresh the board in your browser.
  • Select Resolved in the status filter.
  • Delete each visible resolved ticket.

You will see No tickets in this view. inside the queue card. The total count also decreases after each deletion.

  • Find this reset line inside vm.addTicket:
      vm.newTicket = { title: '', priority: 'medium' };

What Does The Existing Reset Cover?

The current line clears the issue title and restores medium priority. The form controller still remembers its previous interaction state.

  • Add these form-state resets directly below that line:
      form.$setPristine();
      form.$setUntouched();

Why Reset Form State?

The first method clears the dirty and submitted state. The second removes touched state from the controls so old validation feedback does not linger.

  • Add the mobile layout rules at the end of styles.css:
@media (max-width: 620px) {
  .board-heading,
  .ticket {
    align-items: stretch;
    flex-direction: column;
  }

  .ticket-actions button {
    flex: 1;
  }
}

What Happens On Narrow Screens?

The queue heading and ticket rows switch to vertical layouts below 620px. The action buttons share the available row width.

  • Save app.js.
  • Save styles.css.
  • Refresh the board in your browser.

Before the final check, what do you expect the rows and counts to show after one open ticket becomes resolved?

  • Select All in the status filter.
  • Enter Printer offline in the issue field.
  • Select High in the priority field.
  • Select Add ticket.

The new high-priority ticket appears in the queue. The issue field clears without showing old validation feedback.

  • Select Resolve on Printer offline.
  • Select Open in the status filter.
  • Select Resolved in the status filter.
  • Select All in the status filter.

You will see Printer offline disappear from the open view and appear in the resolved view. The total stays constant while the open and resolved counts change immediately.

That completes the triage workflow. Your board can now create, filter, resolve, reopen, count, and delete tickets while keeping every view synchronized.

✔️ Awesome, I've got everything!

Great work. Double check that you saved app.js and styles.css.

ⓧ I'd like to double check the full code

Compare all three files with these completed versions. Match the code and spacing exactly.

(function () {
  'use strict';

  angular
    .module('ticketDesk', [])
    .factory('ticketStore', ticketStore)
    .component('ticketBoard', {
      template: [
        '<main class="shell">',
          '<header class="hero">',
            '<p class="eyebrow">AngularJS {{$ctrl.frameworkVersion}}</p>',
            '<h1>Legacy Help Desk Board</h1>',
            '<p>Create, triage, and resolve support tickets without touching the DOM by hand.</p>',
          '</header>',

          '<section class="grid">',
            '<section class="card">',
              '<h2>Create a ticket</h2>',
              '<form name="$ctrl.ticketForm" ng-submit="$ctrl.addTicket($ctrl.ticketForm)" novalidate>',
                '<label for="title">Issue title</label>',
                '<input id="title" name="title" type="text" ng-model="$ctrl.newTicket.title" ng-maxlength="60" required placeholder="Example: VPN access is blocked">',
                '<p class="hint" ng-if="$ctrl.newTicket.title">Live preview: {{$ctrl.newTicket.title}}</p>',
                '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.required">Enter a ticket title.</p>',
                '<p class="error" ng-if="$ctrl.ticketForm.title.$touched && $ctrl.ticketForm.title.$error.maxlength">Use no more than 60 characters.</p>',

                '<label for="priority">Priority</label>',
                '<select id="priority" ng-model="$ctrl.newTicket.priority">',
                  '<option value="low">Low</option>',
                  '<option value="medium">Medium</option>',
                  '<option value="high">High</option>',
                '</select>',

                '<button class="primary" type="submit" ng-disabled="$ctrl.ticketForm.$invalid">Add ticket</button>',
              '</form>',
            '</section>',

            '<section class="card">',
              '<div class="board-heading">',
                '<div>',
                  '<h2>Ticket queue</h2>',
                  '<p class="stats">Total {{$ctrl.tickets.length}} | Open {{$ctrl.openCount()}} | Resolved {{$ctrl.resolvedCount()}}</p>',
                '</div>',
                '<label class="filter" for="status">',
                  '<span>Show</span>',
                  '<select id="status" ng-model="$ctrl.status">',
                    '<option value="all">All</option>',
                    '<option value="open">Open</option>',
                    '<option value="resolved">Resolved</option>',
                  '</select>',
                '</label>',
              '</div>',

              '<p class="empty" ng-if="($ctrl.tickets | filter:$ctrl.matchesStatus).length === 0">No tickets in this view.</p>',

              '<ul class="ticket-list">',
                '<li class="ticket" ng-class="{isResolved: ticket.resolved}" ng-repeat="ticket in $ctrl.tickets | filter:$ctrl.matchesStatus track by ticket.id">',
                  '<div class="ticket-copy">',
                    '<strong>{{ticket.title}}</strong>',
                    '<div class="ticket-meta">',
                      '<span class="priority priority-{{ticket.priority}}">{{ticket.priority}}</span>',
                      '<span>{{$ctrl.statusLabel(ticket)}}</span>',
                    '</div>',
                  '</div>',
                  '<div class="ticket-actions">',
                    '<button type="button" ng-click="$ctrl.toggleTicket(ticket)">{{$ctrl.toggleLabel(ticket)}}</button>',
                    '<button class="danger" type="button" ng-click="$ctrl.removeTicket(ticket)">Delete</button>',
                  '</div>',
                '</li>',
              '</ul>',
            '</section>',
          '</section>',
        '</main>'
      ].join(''),
      controller: TicketBoardController
    });

  function ticketStore() {
    var nextId = 3;
    var tickets = [
      { id: 1, title: 'Reset email password', priority: 'high', resolved: false },
      { id: 2, title: 'Install finance printer', priority: 'medium', resolved: true }
    ];

    return {
      all: function () {
        return tickets;
      },
      add: function (title, priority) {
        tickets.push({
          id: nextId,
          title: title,
          priority: priority,
          resolved: false
        });
        nextId += 1;
      },
      toggle: function (ticket) {
        ticket.resolved = !ticket.resolved;
      },
      remove: function (ticket) {
        var index = tickets.indexOf(ticket);
        if (index !== -1) {
          tickets.splice(index, 1);
        }
      }
    };
  }

  TicketBoardController.$inject = ['ticketStore'];

  function TicketBoardController(ticketStore) {
    var vm = this;

    vm.frameworkVersion = angular.version.full;
    vm.newTicket = { title: '', priority: 'medium' };
    vm.status = 'all';
    vm.tickets = ticketStore.all();

    vm.addTicket = function (form) {
      var cleanTitle = vm.newTicket.title.trim();

      if (form.$invalid || !cleanTitle) {
        return;
      }

      ticketStore.add(cleanTitle, vm.newTicket.priority);
      vm.newTicket = { title: '', priority: 'medium' };
      form.$setPristine();
      form.$setUntouched();
    };

    vm.toggleTicket = function (ticket) {
      ticketStore.toggle(ticket);
    };

    vm.removeTicket = function (ticket) {
      ticketStore.remove(ticket);
    };

    vm.matchesStatus = function (ticket) {
      if (vm.status === 'open') {
        return !ticket.resolved;
      }

      if (vm.status === 'resolved') {
        return ticket.resolved;
      }

      return true;
    };

    vm.openCount = function () {
      return vm.tickets.filter(function (ticket) {
        return !ticket.resolved;
      }).length;
    };

    vm.resolvedCount = function () {
      return vm.tickets.filter(function (ticket) {
        return ticket.resolved;
      }).length;
    };

    vm.statusLabel = function (ticket) {
      return ticket.resolved ? 'Resolved' : 'Open';
    };

    vm.toggleLabel = function (ticket) {
      return ticket.resolved ? 'Reopen' : 'Resolve';
    };
  }
}());
<!doctype html>
<html lang="en" ng-app="ticketDesk">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Legacy Help Desk Board</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <ticket-board></ticket-board>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  <script src="app.js"></script>
</body>
</html>
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: #eef2f7;
  color: #172033;
  font-family: Arial, sans-serif;
}

button,
input,
select {
  font: inherit;
}

.shell {
  width: min(1100px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0;
}

.hero {
  margin-bottom: 24px;
}

.hero h1 {
  margin: 6px 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

.eyebrow {
  margin: 0;
  color: #3158c9;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
  align-items: start;
}

.card {
  padding: 24px;
  border: 1px solid #d8deea;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
}

.card h2 {
  margin-top: 0;
}

form {
  display: grid;
  gap: 10px;
}

label,
.filter span {
  font-weight: 700;
}

input,
select,
button {
  min-height: 42px;
  border: 1px solid #aeb8ca;
  border-radius: 8px;
  padding: 9px 12px;
}

input.ng-invalid.ng-touched {
  border-color: #b42318;
}

button {
  cursor: pointer;
  background: #ffffff;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.primary {
  border-color: #3158c9;
  background: #3158c9;
  color: #ffffff;
}

.hint {
  margin: 0;
  color: #3158c9;
}

.error {
  margin: 0;
  color: #b42318;
}

.board-heading {
  display: flex;
  gap: 16px;
  justify-content: space-between;
  align-items: end;
}

.stats {
  margin: 4px 0 0;
  color: #526079;
}

.filter {
  display: grid;
  gap: 4px;
}

.ticket-list {
  display: grid;
  gap: 12px;
  margin: 20px 0 0;
  padding: 0;
  list-style: none;
}

.ticket {
  display: flex;
  gap: 12px;
  justify-content: space-between;
  align-items: center;
  padding: 14px;
  border: 1px solid #d8deea;
  border-radius: 10px;
}

.ticket.isResolved strong {
  color: #6b7280;
  text-decoration: line-through;
}

.ticket-copy {
  display: grid;
  gap: 8px;
}

.ticket-meta,
.ticket-actions {
  display: flex;
  gap: 8px;
  align-items: center;
}

.priority {
  border-radius: 999px;
  padding: 3px 8px;
  color: #ffffff;
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
}

.priority-low {
  background: #367a4a;
}

.priority-medium {
  background: #a15c00;
}

.priority-high {
  background: #b42318;
}

.danger {
  border-color: #b42318;
  color: #b42318;
}

.empty {
  padding: 18px;
  border-radius: 10px;
  background: #f7f8fb;
  color: #526079;
  text-align: center;
}

@media (max-width: 620px) {
  .board-heading,
  .ticket {
    align-items: stretch;
    flex-direction: column;
  }

  .ticket-actions button {
    flex: 1;
  }
}

Board Not Matching The Finished Version?

Check that the filter appears before track by ticket.id in the repeater. Confirm that every controller method remains inside TicketBoardController.

Compare the closing quotes, commas, and braces with the full code tab. Help me compare my completed AngularJS help desk files.

Secret mission

Extract a Priority Badge Component

Move the repeated priority badge into a reusable AngularJS child component. Each ticket passes its priority through a one-way binding. The board keeps its existing triage behavior.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs from local files with no ongoing costs. Choose whether to keep the board open, close it for later, or delete it entirely.

Resources you used:

  • A browser tab displaying the board from index.html.
  • The local angularjs-help-desk folder containing index.html, app.js, and styles.css.

Keep everything running

No action is needed. Choose this if you want to keep testing the ticket workflow or extending the AngularJS board.

  • Keep the browser tab displaying index.html open.
  • Keep the angularjs-help-desk folder with its three project files.
  • Keep an internet connection available when reloading the board so the browser can load AngularJS from Google Hosted Libraries.

Pause - I'll come back to this later

Close the local board while keeping every project file for later. Your saved code remains ready to reopen.

  • Close the browser tab displaying the help desk board.
  • Keep the angularjs-help-desk folder in its current location.
  • Return to the angularjs-help-desk folder in File Explorer when you want to continue.
  • Double-click index.html to load the board again.

Delete - I don't want to use this again

Deleting your board is permanent. The command below targets only the folder path you provide.

  • Close the browser tab displaying the help desk board.
  • In File Explorer, return to the location containing the angularjs-help-desk folder.
  • Record the full folder path here: full path to angularjs-help-desk.
  • Check that the recorded path ends with angularjs-help-desk.
  • Press the Windows key to open system search.
  • Search for the classic Windows command shell.
  • Open the matching desktop app.
  • Permanently delete the project folder by running this command:
rmdir /s /q "[[PROJECT_FOLDER_PATH="full path to angularjs-help-desk"]]"

What does this command remove?

This command permanently removes the angularjs-help-desk folder at the path you recorded. Its three project files are deleted with it.

  • Return to File Explorer.
  • Confirm that the angularjs-help-desk folder no longer appears in its previous location.

Folder still present?

  • Check that full path to angularjs-help-desk points to the existing project folder.
  • Use the classic Windows command shell because another shell can interpret the command differently.
  • Help me remove my local project folder.

Nice Work!

Nice Work!

Fantastic work! You built a browser-based help desk board with AngularJS 1.8.2.

You've learned how to:

  • Create a validated ticket form with a live preview powered by two-way data binding.
  • Move ticket state into the injected ticketStore service through dependency injection. Give every ticket a stable ID so duplicate titles render as separate rows.
  • Build a triage view with status filtering. Keep its live counts in sync after every ticket action.
  • Secret Mission: Extract priority rendering into the reusable priorityBadge component with a one-way binding through level.

Ready to quiz yourself?