Sprint Name Generator In Angular 9.1.3
I've had a stressful week. So, I wanted to cap it off with something a little on the fun side. At work, we've started to use "Sprints" to organize our team (as opposed to just working a Kanban board). And, as part of the Sprint planning, we have to pick a name the Sprint. As such, I thought it would be a fun kata to create a small application that generates Sprint names using Angular 9.1.3.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
The Sprint name generator works by randomly selecting a "Description" and an "Animal" and then pairing the two together in order to form the generated name. To be honest, I spent about 90% of the time compiling the list of inputs and only about 10% of the time building the actual Angular application. As I was saying, it's been a stressful week and looking up "animals of the rain forest" was actually quite relaxing.
The user interface (UI) is extremely minimal: just a button that generates new names and a slot-machine-like viewport into the name-parts as they are sliding onto the screen. When a new name is generated, it gets logged to the console and copied into the user's clipboard.
CAUTION: For some reason, the first run of the page never copies to the value to the user's clipboard. I assume this is some sort of security issue - perhaps the browser-commands don't work unless they are specifically triggered by a user interaction? I'm not sure. But, the clipboard seems to work on all subsequent button-clicks.
Before we look at the code, let's look at the UI to get a sense of what's going on:
As you can see, when the user clicks the button, a new Description and Animal are selected. The composite Sprint name is then logged to the console and copied to the user's clipboard.
The code for this Angular application is quite minimal - all the "fancy UI" stuff is little more than a dynamic translateY()
style
attribute that offsets each list in order to render the desired index. Here's the App component - the public generateName()
method is what gets called when the user clicks the button in the UI.
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { descriptions } from "./dictionaries/descriptions";
import { things } from "./dictionaries/things";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public descriptionIndex: number;
public descriptions: string[];
public sprintName: string;
public thingIndex: number;
public things: string[];
// I initialize the app component.
constructor() {
this.descriptionIndex = 0;
this.descriptions = descriptions;
this.sprintName = "";
this.thingIndex = 0;
this.things = things;
this.generateName();
}
// ---
// PUBLIC METHODS.
// ---
// I generate the next Sprint Name by randomly selecting a Description and a Thing
// and then joining the two values.
public generateName() : void {
// Randomly select next parts of the name.
this.descriptionIndex = this.nextIndex( this.descriptionIndex, this.descriptions );
this.thingIndex = this.nextIndex( this.thingIndex, this.things );
this.sprintName = (
this.descriptions[ this.descriptionIndex ] +
" " +
this.things[ this.thingIndex ]
);
this.shareSprintNameWithUser( this.sprintName );
}
// ---
// PRIVATE METHODS.
// ---
// I try to copy the value to the user's clipboard. Returns Boolean indicating
// whether or not the operation appeared to be successful.
private copyToClipboard( value: string ) : boolean {
// In order to execute the "Copy" command, we actually have to have a "selection"
// in the rendered document. As such, we're going to inject a Textarea element,
// populate it with the given value, select it, and then copy it. Since this
// operation is going to change the document selection, let's get a reference to
// the currently-active element (expected to be our "Generate" button) such that
// we can return focus after the copy command has executed.
var activeElement = <HTMLElement | null>document.activeElement;
var textarea: HTMLTextAreaElement = document.createElement( "textarea" );
textarea.style.opacity = "0";
textarea.style.position = "fixed";
textarea.value = value;
// Set and select the value (creating an active Selection range).
document.body.appendChild( textarea );
textarea.select();
try {
// CAUTION: Even though this may not throw an error, the COPY command does
// not appear to work unless it is in response to a direct user interaction.
// Meaning, nothing gets copied until the user actually CLICKS the button to
// generate a new name. Not sure why that is? Maybe a security issue?
document.execCommand( "copy" );
return( true );
} catch ( error ) {
return( false );
} finally {
// Return focus to the active element, if we had one.
if ( activeElement ) {
activeElement.focus();
}
document.body.removeChild( textarea );
}
}
// I return a random index for selection within the given collection.
private nextIndex( currentIndex: number, collection: any[] ) : number {
var nextIndex = currentIndex;
var length = collection.length;
// Keep generating a random index until we get a non-matching value. This just
// ensures some "change" from generation to generation.
while ( nextIndex === currentIndex ) {
nextIndex = ( Math.floor( Math.random() * length ) );
}
return( nextIndex );
}
// I share the given Sprint Name with the user.
private shareSprintNameWithUser( sprintName: string ) : void {
// As a convenience, try to copy the new name to the user's clipboard.
var nameWasCopied = this.copyToClipboard( sprintName );
// Also, let's log the name to the user's console.
console.group(
"%c Sprint Name Generator ",
"background-color: #121212 ; color: #ffffff ;"
);
console.log(
`%c${ sprintName }`,
"color: #ff3366 ;"
);
if ( nameWasCopied ) {
console.log(
"%cThis name was copied to your clipboard.",
"font-style: italic ;"
);
}
console.groupEnd();
}
}
As you can see, the logic in the App component does nothing more than randomly select the items and then try to copy the composite value to the user's clipboard.
The App View uses flexbox
to place the two lists next to each other and a little translateY()
magic to make sure the selected item in each list is the one rendered on the page:
<h1>
Sprint Name Generator In Angular 9.1.3
</h1>
<div class="parts">
<div class="part">
<ul
class="items"
[style.transform]="( 'translateY( -' + ( descriptionIndex * 85 ) + 'px )' )">
<li
*ngFor="let description of descriptions ; let i = index ;"
class="item"
[class.selected]="( i === descriptionIndex )">
{{ description }}
</li>
</ul>
</div>
<div class="part">
<ul
class="items"
[style.transform]="( 'translateY( -' + ( thingIndex * 85 ) + 'px )' )">
<li
*ngFor="let thing of things ; let i = index ;"
class="item"
[class.selected]="( i === thingIndex )">
{{ thing }}
</li>
</ul>
</div>
</div>
<div class="actions">
<button (click)="generateName()" autofocus class="action">
Generate Sprint Name
</button>
</div>
Notice that each list is offset by a multiple of 85
. This number represents the height of each item in the list. You can think of this UI kind of like a slide-show in which the UI only allows one slide (ie, list-item) to be shown at a time. I'm then using CSS to determine which slide is in front of the viewport.
When the translateY()
offset is -0px
, the first slide is show. When the offset is -85px
, the second slide is shown. When the offset is -170px
, the third slide is shown. And so on. And, by adding the transform
CSS property to the list of properties that are going to transition
, you get the fun slot-machine-like experience (see video or try demo).
You can see the LESS CSS for this view here:
// This is the height of each item in the list of Descriptions and Things. The rendering
// works by offsetting each list by some negative-multiple of this value (see HTML).
@itemHeight: 85px ;
:host {
box-sizing: border-box ;
display: flex ;
flex-direction: column ;
font-size: 18px ;
justify-content: space-between ;
min-height: 100vh ;
padding: 30px 30px 30px 30px ;
}
h1 {
flex: 0 0 auto ;
margin: 0px 0px 0px 0px ;
text-align: center ;
}
.parts {
display: flex ;
}
.part {
height: @itemHeight ;
margin: 10px 10px 10px 10px ;
overflow: hidden ;
width: 50% ;
&:first-child {
text-align: right ;
}
}
.items {
list-style-type: none ;
margin: 0px 0px 0px 0px ;
padding: 0px 0px 0px 0px ;
transition: transform 1000ms ease-in-out ;
}
.item {
font-size: 60px ;
font-weight: 700 ;
height: @itemHeight ;
line-height: @itemHeight ;
margin: 0px 0px 0px 0px ;
padding: 0px 0px 0px 0px ;
&:not(.selected) {
user-select: none ;
-moz-user-select: none ;
-webkit-user-select: none ;
}
}
.actions {
display: flex ;
flex: 0 0 auto ;
justify-content: center ;
}
.action {
background-color: #ff3366 ;
border-radius: 8px 8px 8px 8px ;
border-width: 0px 0px 0px 0px ;
color: #ffffff ;
cursor: pointer ;
font-size: 20px ;
font-weight: 400 ;
letter-spacing: 0.5px ;
padding: 20px 30px 20px 30px ;
&:active {
background-color: darken( #ff3366, 10% ) ;
}
}
And that's all there is to it!
This was a fun way to close-out the week. I know I've been digging into a lot of server-side stuff lately; so, diving back into some client-side Angular code was a nice little palette-cleanser.
Mobile Friendly Update - April 26, 2020
After deploying this and sharing it with my team, I tried to access it from my iPhone and found the experience to be rather poor. I usually only use these Angular demos on my desktop; but, these Sprint names are too fun - I kept going back and trying to generate more of them.
Anyway, this morning, I went back and attempted to make the LESS CSS a bit more mobile friendly. I'm not that good at mobile friendly layouts; and, ideally, this would have animated differently at smaller scale; but, that would have required me figuring out how to not hard-code 85
in the transform
CSS property.
Oh well, here's the minimum-effort version of the mobile-friendly CSS:
// This is the height of each item in the list of Descriptions and Things. The rendering
// works by offsetting each list by some negative-multiple of this value (see HTML).
@itemHeight: 85px ;
:host {
box-sizing: border-box ;
display: flex ;
flex-direction: column ;
font-size: 18px ;
justify-content: space-between ;
min-height: 100vh ;
padding: 30px 30px 30px 30px ;
}
h1 {
flex: 0 0 auto ;
font-size: 22px ;
line-height: 30px ;
margin: 0px 0px 0px 0px ;
text-align: center ;
@media screen and ( min-width: 700px ) {
font-size: 30px ;
line-height: 38px ;
}
@media screen and ( min-width: 900px ) {
font-size: 38px ;
line-height: 45px ;
}
}
.parts {
display: flex ;
flex-direction: column ;
@media screen and ( min-width: 1175px ) {
flex-direction: row ;
}
}
.part {
height: @itemHeight ;
margin: 0px 0px 0px 0px ;
overflow: hidden ;
text-align: center ;
@media screen and ( min-width: 1175px ) {
margin: 10px 10px 10px 10px ;
text-align: left ;
width: 50% ;
&:first-child {
text-align: right ;
}
}
}
.items {
list-style-type: none ;
margin: 0px 0px 0px 0px ;
padding: 0px 0px 0px 0px ;
transition: transform 1000ms ease-in-out ;
}
.item {
font-size: 32px ;
font-weight: 700 ;
height: @itemHeight ;
line-height: @itemHeight ;
margin: 0px 0px 0px 0px ;
padding: 0px 0px 0px 0px ;
@media screen and ( min-width: 550px ) {
font-size: 50px ;
}
@media screen and ( min-width: 675px ) {
font-size: 60px ;
}
&:not(.selected) {
user-select: none ;
-moz-user-select: none ;
-webkit-user-select: none ;
}
}
.actions {
display: flex ;
flex: 0 0 auto ;
justify-content: center ;
}
.action {
background-color: #ff3366 ;
border-radius: 8px 8px 8px 8px ;
border-width: 0px 0px 0px 0px ;
color: #ffffff ;
cursor: pointer ;
font-size: 20px ;
font-weight: 400 ;
letter-spacing: 0.5px ;
padding: 20px 30px 20px 30px ;
&:active {
background-color: darken( #ff3366, 10% ) ;
}
}
The default CSS for now for a vertical layout; then, as the screen gets wider, the flex
layout switches from column
to row
. Again, I'm leaving the height
of the individual items the same, which looks strange at the smaller resolution; but, it was sufficient for the demo.
Want to use code from this post? Check out the license.
Reader Comments
@All
I moved this to a Netlify App so that I could evolve it independently of this blog post:
URL: https://sprint-names.netlify.app/
Git repo: https://github.com/bennadel/Sprint-Name-Generator