diff --git a/web/app/components/new/document-template-list.hbs b/web/app/components/new/document-template-list.hbs
index ce3192e52..93e5d1bd2 100644
--- a/web/app/components/new/document-template-list.hbs
+++ b/web/app/components/new/document-template-list.hbs
@@ -1,4 +1,4 @@
-
+
Choose a template
@@ -6,7 +6,7 @@
or
-
+
{{/if}}
-
-
- Whatʼs a project
-
-
-
+
{{#each this.docTypes as |docType|}}
diff --git a/web/app/components/new/project-form.hbs b/web/app/components/new/project-form.hbs
index a9d9dc15d..0746338c3 100644
--- a/web/app/components/new/project-form.hbs
+++ b/web/app/components/new/project-form.hbs
@@ -14,7 +14,7 @@
-
diff --git a/web/app/components/project/tile.ts b/web/app/components/project/tile.ts
index 5c6673920..04325cd1f 100644
--- a/web/app/components/project/tile.ts
+++ b/web/app/components/project/tile.ts
@@ -6,6 +6,8 @@ import ConfigService from "hermes/services/config";
import FetchService from "hermes/services/fetch";
import { HermesProject, JiraIssue } from "hermes/types/project";
+export const PROJECT_TILE_MAX_PRODUCTS = 3;
+
interface ProjectTileComponentSignature {
Element: HTMLDivElement;
Args: {
@@ -25,12 +27,36 @@ export default class ProjectTileComponent extends Component this.maxProducts;
+ }
+
+ /**
+ * The number of additional products that are not shown in the product avatars.
+ * Rendered if `additionalProductsLabelIsShown` is true.
+ */
+ protected get additionalProductsCount() {
+ const { products } = this.args.project;
+ return (products?.length ?? 0) - this.maxProducts;
+ }
/**
* Whether the Jira issue is marked "close" or "done."
diff --git a/web/app/components/projects/index.hbs b/web/app/components/projects/index.hbs
index da4e57081..21c989385 100644
--- a/web/app/components/projects/index.hbs
+++ b/web/app/components/projects/index.hbs
@@ -1,30 +1,26 @@
-
+
+
+
+
-
+
{{#each this.shownProjects as |project|}}
-
-
-
-
+
{{/each}}
diff --git a/web/app/components/whats-a-project.hbs b/web/app/components/whats-a-project.hbs
new file mode 100644
index 000000000..067489291
--- /dev/null
+++ b/web/app/components/whats-a-project.hbs
@@ -0,0 +1,14 @@
+
+
+ Whatʼs a project?
+
+
diff --git a/web/app/components/whats-a-project.ts b/web/app/components/whats-a-project.ts
new file mode 100644
index 000000000..e3ca9b49f
--- /dev/null
+++ b/web/app/components/whats-a-project.ts
@@ -0,0 +1,13 @@
+import Component from "@glimmer/component";
+
+interface WhatsAProjectComponentSignature {
+ Element: HTMLDivElement;
+}
+
+export default class WhatsAProjectComponent extends Component {}
+
+declare module "@glint/environment-ember-loose/registry" {
+ export default interface Registry {
+ WhatsAProject: typeof WhatsAProjectComponent;
+ }
+}
diff --git a/web/app/helpers/get-project-status-icon.ts b/web/app/helpers/get-project-status-icon.ts
deleted file mode 100644
index c806836c2..000000000
--- a/web/app/helpers/get-project-status-icon.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { helper } from "@ember/component/helper";
-import {
- ProjectStatus,
- projectStatusObjects,
-} from "hermes/types/project-status";
-
-export interface GetProjectStatusIconSignature {
- Args: {
- Positional: [string | undefined];
- };
- Return: string | undefined;
-}
-
-const getProjectStatusIcon = helper(
- ([status]) => {
- switch (status) {
- case ProjectStatus.Active:
- return projectStatusObjects[ProjectStatus.Active].icon;
- case ProjectStatus.Completed:
- return projectStatusObjects[ProjectStatus.Completed].icon;
- case ProjectStatus.Archived:
- return projectStatusObjects[ProjectStatus.Archived].icon;
- default:
- return;
- }
- },
-);
-
-export default getProjectStatusIcon;
-
-declare module "@glint/environment-ember-loose/registry" {
- export default interface Registry {
- "get-project-status-icon": typeof getProjectStatusIcon;
- }
-}
diff --git a/web/app/modifiers/tooltip.ts b/web/app/modifiers/tooltip.ts
index 37dfed920..8a8f0e1d1 100644
--- a/web/app/modifiers/tooltip.ts
+++ b/web/app/modifiers/tooltip.ts
@@ -56,6 +56,13 @@ interface TooltipModifierNamedArgs {
delay?: number;
openDuration?: number;
class?: string;
+
+ /**
+ * Whether an element should be focusable. Inherently true of interactive elements;
+ * true by design for non-interactive elements which receive `tabindex="0"`
+ * unless `focusable` is explicitly set false.
+ */
+ focusable?: boolean;
_useTestDelay?: boolean;
}
@@ -558,10 +565,18 @@ export default class TooltipModifier extends Modifier
this._reference.setAttribute("data-tooltip-state", this.state);
/**
- * If the reference isn't inherently focusable, make it focusable.
+ * If the consumer has explicitly set `focusable` to false,
+ * set the `tabindex` to -1 so the reference can't be focused.
*/
- if (!this._reference.matches(FOCUSABLE)) {
- this._reference.setAttribute("tabindex", "0");
+ if (named.focusable === false) {
+ this._reference.setAttribute("tabindex", "-1");
+ } else {
+ /**
+ * If the reference isn't inherently focusable, make it focusable.
+ */
+ if (!this._reference.matches(FOCUSABLE)) {
+ this._reference.setAttribute("tabindex", "0");
+ }
}
document.addEventListener("keydown", this.handleKeydown);
diff --git a/web/app/styles/components/projects/tile.scss b/web/app/styles/components/projects/tile.scss
index ed2baa5a2..93142795d 100644
--- a/web/app/styles/components/projects/tile.scss
+++ b/web/app/styles/components/projects/tile.scss
@@ -1,18 +1,19 @@
-.project-tile {
- .title,
- .description {
- @apply overflow-hidden;
- display: -webkit-box;
- -webkit-box-orient: vertical;
+.project-tile-grid {
+ grid-template-columns: 1fr 120px 160px;
+
+ .title-and-description {
+ grid-column: 1;
+
+ .inner {
+ grid-template-columns: 16px 1fr;
+ }
}
- .title {
- -webkit-line-clamp: 2;
- line-clamp: 2;
+ .jira {
+ grid-column: 2;
}
- .description {
- -webkit-line-clamp: 3;
- line-clamp: 3;
+ .products {
+ grid-column: 3;
}
}
diff --git a/web/app/types/project-status.ts b/web/app/types/project-status.ts
index 718410e07..df2cbd32b 100644
--- a/web/app/types/project-status.ts
+++ b/web/app/types/project-status.ts
@@ -6,21 +6,28 @@ export enum ProjectStatus {
export type ProjectStatusObject = {
label: string;
- icon: string;
};
export const projectStatusObjects: Record =
{
[ProjectStatus.Active]: {
label: "Active",
- icon: "zap",
},
[ProjectStatus.Completed]: {
label: "Completed",
- icon: "check-circle",
},
[ProjectStatus.Archived]: {
label: "Archived",
- icon: "archive",
},
};
+
+export const COLOR_BG_ACTIVE = "var(--token-color-palette-purple-100)";
+export const COLOR_OUTLINE_ACTIVE = "var(--token-color-palette-purple-200)";
+export const COLOR_ICON_ACTIVE = "var(--token-color-palette-purple-400)";
+
+export const COLOR_BG_COMPLETED = "var(--token-color-palette-green-100)";
+export const COLOR_OUTLINE_COMPLETED = "var(--token-color-palette-green-200)";
+export const COLOR_ICON_COMPLETED = "var(--token-color-palette-green-300)";
+
+export const COLOR_BG_ARCHIVED = "var(--token-color-palette-neutral-200)";
+export const COLOR_OUTLINE_ARCHIVED = "var(--token-color-palette-neutral-400)";
diff --git a/web/tests/acceptance/authenticated/new/project-test.ts b/web/tests/acceptance/authenticated/new/project-test.ts
index 65b815fe6..b82773729 100644
--- a/web/tests/acceptance/authenticated/new/project-test.ts
+++ b/web/tests/acceptance/authenticated/new/project-test.ts
@@ -134,7 +134,7 @@ module("Acceptance | authenticated/new/project", function (hooks) {
await visit("new/project");
assert.dom(HEADLINE).hasText("Start a project");
- assert.dom(ICON).hasAttribute("data-test-icon", "grid");
+ assert.dom(ICON).hasAttribute("data-test-icon", "folder");
assert.dom(TASK_IS_RUNNING_DESCRIPTION).doesNotExist();
await fillIn(TITLE_INPUT, "The Foo Project");
diff --git a/web/tests/acceptance/authenticated/projects-test.ts b/web/tests/acceptance/authenticated/projects-test.ts
index a16a12d94..13619bf99 100644
--- a/web/tests/acceptance/authenticated/projects-test.ts
+++ b/web/tests/acceptance/authenticated/projects-test.ts
@@ -10,7 +10,6 @@ import { ProjectStatus } from "hermes/types/project-status";
const PROJECT_TILE = "[data-test-project-tile]";
const PROJECT_TITLE = `${PROJECT_TILE} [data-test-title]`;
-const PROJECT_DESCRIPTION = `${PROJECT_TILE} [data-test-description]`;
const PROJECT_PRODUCT = `${PROJECT_TILE} [data-test-product]`;
const PROJECT_JIRA_TYPE = `${PROJECT_TILE} [data-test-issue-type-image]`;
const PROJECT_JIRA_KEY = `${PROJECT_TILE} [data-test-jira-key]`;
@@ -58,7 +57,6 @@ module("Acceptance | authenticated/projects", function (hooks) {
assert.dom("[data-test-project]").exists({ count: 3 });
let expectedTitles: string[] = [];
- let expectedDescriptions: string[] = [];
let expectedProductCount = 0;
let expectedKeys: string[] = [];
let expectedJiraTypes: string[] = [];
@@ -68,10 +66,6 @@ module("Acceptance | authenticated/projects", function (hooks) {
.models.forEach((project: HermesProject) => {
expectedTitles.push(project.title);
- if (project.description) {
- expectedDescriptions.push(project.description);
- }
-
if (project.jiraIssueID) {
const jiraIssue = this.server.schema.jiraIssues.findBy({
key: project.jiraIssueID,
@@ -89,10 +83,6 @@ module("Acceptance | authenticated/projects", function (hooks) {
(e) => e.textContent?.trim(),
);
- const renderedDescriptions = findAll(PROJECT_DESCRIPTION).map(
- (e) => e.textContent?.trim(),
- );
-
const renderedProductsCount = findAll(PROJECT_PRODUCT).length;
const renderedKeys = findAll(PROJECT_JIRA_KEY).map(
@@ -104,7 +94,6 @@ module("Acceptance | authenticated/projects", function (hooks) {
);
assert.deepEqual(renderedTitles, expectedTitles);
- assert.deepEqual(renderedDescriptions, expectedDescriptions);
assert.deepEqual(renderedProductsCount, expectedProductCount);
assert.deepEqual(renderedKeys, expectedKeys);
assert.deepEqual(renderedJiraTypes, expectedJiraTypes);
diff --git a/web/tests/integration/components/project/status-icon-test.ts b/web/tests/integration/components/project/status-icon-test.ts
index d58644f5f..17f6e5350 100644
--- a/web/tests/integration/components/project/status-icon-test.ts
+++ b/web/tests/integration/components/project/status-icon-test.ts
@@ -1,10 +1,25 @@
import { TestContext, render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { setupRenderingTest } from "ember-qunit";
-import { ProjectStatus } from "hermes/types/project-status";
+import {
+ COLOR_BG_ACTIVE,
+ COLOR_BG_ARCHIVED,
+ COLOR_BG_COMPLETED,
+ COLOR_ICON_ACTIVE,
+ COLOR_ICON_COMPLETED,
+ COLOR_OUTLINE_ACTIVE,
+ COLOR_OUTLINE_ARCHIVED,
+ COLOR_OUTLINE_COMPLETED,
+ ProjectStatus,
+} from "hermes/types/project-status";
import { module, test } from "qunit";
const ICON = "[data-test-project-status-icon]";
+const BACKGROUND = "[data-test-background]";
+const OUTLINE = "[data-test-outline]";
+const ACTIVE_AFFORDANCE = "[data-test-active-affordance]";
+const COMPLETED_AFFORDANCE = "[data-test-completed-affordance]";
+const ARCHIVED_AFFORDANCE = "[data-test-archived-affordance]";
interface Context extends TestContext {
status: `${ProjectStatus}`;
@@ -13,7 +28,7 @@ interface Context extends TestContext {
module("Integration | Component | project/status-icon", function (hooks) {
setupRenderingTest(hooks);
- test("it renders the correct icon based on status", async function (assert) {
+ test("it renders correctly based on status", async function (assert) {
this.set("status", ProjectStatus.Active);
await render(hbs`
@@ -22,12 +37,32 @@ module("Integration | Component | project/status-icon", function (hooks) {
assert.dom(ICON).hasAttribute("data-test-status", ProjectStatus.Active);
+ assert.dom(BACKGROUND).hasAttribute("data-test-color", COLOR_BG_ACTIVE);
+ assert.dom(OUTLINE).hasAttribute("data-test-color", COLOR_OUTLINE_ACTIVE);
+ assert
+ .dom(ACTIVE_AFFORDANCE)
+ .hasAttribute("data-test-color", COLOR_ICON_ACTIVE);
+
this.set("status", ProjectStatus.Completed);
assert.dom(ICON).hasAttribute("data-test-status", ProjectStatus.Completed);
+ assert.dom(BACKGROUND).hasAttribute("data-test-color", COLOR_BG_COMPLETED);
+ assert
+ .dom(OUTLINE)
+ .hasAttribute("data-test-color", COLOR_OUTLINE_COMPLETED);
+ assert
+ .dom(COMPLETED_AFFORDANCE)
+ .hasAttribute("data-test-color", COLOR_ICON_COMPLETED);
+
this.set("status", ProjectStatus.Archived);
assert.dom(ICON).hasAttribute("data-test-status", ProjectStatus.Archived);
+
+ assert.dom(BACKGROUND).hasAttribute("data-test-color", COLOR_BG_ARCHIVED);
+ assert.dom(OUTLINE).hasAttribute("data-test-color", COLOR_OUTLINE_ARCHIVED);
+ assert
+ .dom(ARCHIVED_AFFORDANCE)
+ .hasAttribute("data-test-color", COLOR_OUTLINE_ARCHIVED);
});
});
diff --git a/web/tests/integration/components/project/tile-test.ts b/web/tests/integration/components/project/tile-test.ts
index bb5436340..76554126e 100644
--- a/web/tests/integration/components/project/tile-test.ts
+++ b/web/tests/integration/components/project/tile-test.ts
@@ -1,4 +1,4 @@
-import { render, rerender, settled } from "@ember/test-helpers";
+import { render, settled } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support";
import { setupRenderingTest } from "ember-qunit";
@@ -8,12 +8,15 @@ import { assert as emberAssert } from "@ember/debug";
import htmlElement from "hermes/utils/html-element";
import { RelatedHermesDocument } from "hermes/components/related-resources";
import { setupProductIndex } from "hermes/tests/mirage-helpers/utils";
+import { PROJECT_TILE_MAX_PRODUCTS } from "hermes/components/project/tile";
const PROJECT_TITLE = "[data-test-title]";
-const PROJECT_DESCRIPTION = "[data-test-description]";
+const JIRA_LINK = "[data-test-jira-link]";
const PROJECT_JIRA_TYPE_IMAGE = "[data-test-issue-type-image]";
const PROJECT_JIRA_KEY = "[data-test-jira-key]";
+const PRODUCT_LINK = "[data-test-product] a";
const PRODUCT_AVATAR = "[data-test-product-avatar]";
+const ADDITIONAL_PRODUCTS_LABEL = "[data-test-additional-products-label]";
interface ProjectTileComponentTestContext extends MirageTestContext {
project: HermesProject;
@@ -83,11 +86,6 @@ module("Integration | Component | project/tile", function (hooks) {
emberAssert("description must exist", description);
- assert.dom(PROJECT_DESCRIPTION).hasText(description);
-
- this.set("project.description", null);
-
- assert.dom(PROJECT_DESCRIPTION).doesNotExist();
assert.dom(PROJECT_JIRA_KEY).doesNotExist();
});
@@ -102,6 +100,7 @@ module("Integration | Component | project/tile", function (hooks) {
const { key, issueType } = issue.attrs;
+ assert.dom(JIRA_LINK).hasAttribute("href", issue.url);
assert.dom(PROJECT_JIRA_KEY).hasText(key);
assert.dom(PROJECT_JIRA_TYPE_IMAGE).hasAttribute("alt", issueType);
});
@@ -116,6 +115,14 @@ module("Integration | Component | project/tile", function (hooks) {
this.set("project.products", ["Vault", "Hermes"]);
assert.dom(PRODUCT_AVATAR).exists({ count: 2 });
+
+ assert
+ .dom(PRODUCT_LINK)
+ .hasAttribute(
+ "href",
+ "/product-areas/vault",
+ "url is correctly dasherized",
+ );
});
test('if the status of a jiraIssue includes "done" or "closed," the key is rendered with a line through it', async function (this: ProjectTileComponentTestContext, assert) {
@@ -167,14 +174,12 @@ module("Integration | Component | project/tile", function (hooks) {
assert.dom(PROJECT_JIRA_KEY).hasClass("line-through");
});
- test("it truncates long titles and descriptions", async function (this: ProjectTileComponentTestContext, assert) {
+ test("it truncates long titles", async function (this: ProjectTileComponentTestContext, assert) {
this.set(
"project",
this.server.create("project", {
title:
"This is a long text string that should be truncated. It goes on and on and on, and then, wouldn't you know it, it goes on some more.",
- description:
- "This is a long text string that should be truncated. It goes on and on and on, and then, wouldn't you know it, it goes on some more.",
}),
);
@@ -185,7 +190,6 @@ module("Integration | Component | project/tile", function (hooks) {
`);
const titleHeight = htmlElement(PROJECT_TITLE).offsetHeight;
- const descriptionHeight = htmlElement(PROJECT_DESCRIPTION).offsetHeight;
const titleLineHeight = Math.ceil(
parseFloat(
@@ -193,13 +197,27 @@ module("Integration | Component | project/tile", function (hooks) {
),
);
- const descriptionLineHeight = Math.ceil(
- parseFloat(
- window.getComputedStyle(htmlElement(PROJECT_DESCRIPTION)).lineHeight,
- ),
+ assert.equal(
+ titleHeight,
+ titleLineHeight,
+ "long title remains only one line",
);
+ });
+
+ test("it truncates the number of project avatars", async function (this: ProjectTileComponentTestContext, assert) {
+ this.set("project.products", [
+ "Vault",
+ "Hermes",
+ "Terraform",
+ "Waypoint",
+ "Consul",
+ ]);
+
+ await render(hbs`
+
+ `);
- assert.equal(titleHeight, titleLineHeight * 2);
- assert.equal(descriptionHeight, descriptionLineHeight * 3);
+ assert.dom(PRODUCT_AVATAR).exists({ count: PROJECT_TILE_MAX_PRODUCTS });
+ assert.dom(ADDITIONAL_PRODUCTS_LABEL).hasText("+2");
});
});
diff --git a/web/tests/integration/modifiers/tooltip-test.ts b/web/tests/integration/modifiers/tooltip-test.ts
index d37800cc9..60b434ef2 100644
--- a/web/tests/integration/modifiers/tooltip-test.ts
+++ b/web/tests/integration/modifiers/tooltip-test.ts
@@ -16,6 +16,10 @@ module("Integration | Modifier | tooltip", function (hooks) {
+
+
+ Hover or focus me
+
`);
assert
@@ -23,16 +27,18 @@ module("Integration | Modifier | tooltip", function (hooks) {
.hasAttribute(
"tabindex",
"0",
- "div is not focusable, so it's given a tabindex of 0"
+ "div is not focusable, so it's given a tabindex of 0",
);
assert
.dom("button")
.doesNotHaveAttribute(
"tabindex",
- "button is focusable, so it's not given a tabindex"
+ "button is focusable, so it's not given a tabindex",
);
+ assert.dom("span").hasAttribute("tabindex", "-1", "span is not focusable");
+
let divTooltipSelector =
"#" + htmlElement("[data-test-div]").getAttribute("aria-describedby");
@@ -42,7 +48,7 @@ module("Integration | Modifier | tooltip", function (hooks) {
assert.notEqual(
divTooltipSelector,
buttonTooltipSelector,
- "div and button have unique tooltip ids"
+ "div and button have unique tooltip ids",
);
assert.dom(".hermes-tooltip").doesNotExist("tooltips hidden by default");
@@ -71,6 +77,8 @@ module("Integration | Modifier | tooltip", function (hooks) {
await triggerEvent("[data-test-button]", "mouseenter");
assert.dom(buttonTooltipSelector).exists();
+
+ await triggerEvent("[data-test-button]", "mouseleave");
});
test("it takes a placement argument", async function (assert) {
@@ -99,7 +107,7 @@ module("Integration | Modifier | tooltip", function (hooks) {
.hasAttribute(
"data-tooltip-placement",
"top",
- "tooltip is placed top by default"
+ "tooltip is placed top by default",
);
await triggerEvent("[data-test-two]", "mouseenter");
@@ -109,7 +117,7 @@ module("Integration | Modifier | tooltip", function (hooks) {
.hasAttribute(
"data-tooltip-placement",
"left-end",
- "tooltip can be custom placed"
+ "tooltip can be custom placed",
);
});