diff --git a/web/app/components/document/sidebar/related-resources.ts b/web/app/components/document/sidebar/related-resources.ts index 06f0d1fd4..498fb5aa0 100644 --- a/web/app/components/document/sidebar/related-resources.ts +++ b/web/app/components/document/sidebar/related-resources.ts @@ -15,14 +15,10 @@ import { RelatedExternalLink, RelatedHermesDocument, RelatedResource, + RelatedResourceSelector, } from "hermes/components/related-resources"; import { assert } from "@ember/debug"; -enum RelatedResourceSelector { - ExternalLink = ".external-resource", - HermesDocument = ".hermes-document", -} - export interface DocumentSidebarRelatedResourcesComponentArgs { productArea?: string; objectID?: string; diff --git a/web/app/components/overflow-menu.hbs b/web/app/components/overflow-menu.hbs index e40fc641b..1e7003679 100644 --- a/web/app/components/overflow-menu.hbs +++ b/web/app/components/overflow-menu.hbs @@ -12,7 +12,10 @@ {{if dd.contentIsShown 'visible' 'invisible'}} " > - +
diff --git a/web/app/components/project/index.hbs b/web/app/components/project/index.hbs new file mode 100644 index 000000000..7da9436ee --- /dev/null +++ b/web/app/components/project/index.hbs @@ -0,0 +1,443 @@ +
+ +
+
+ {{! Placeholder until we have ProductAvatars }} + +
+
+ +
+
+ + + Status: + + + + <:anchor as |dd|> + + + + {{this.statusLabel}} + + + + + <:item as |dd|> + + + <:default> +
+ + + {{dd.attrs.label}} + +
+ +
+
+ +
+
+ + {{! TODO }} + + +
+
+ +{{! Title }} +
+ +
+ +{{! Description }} +
+ +
+ +{{! Jira }} +{{! Currently placeholder }} +
+ {{#if this.jiraIssue}} +
+ +
+
+ +
+ {{#if this.jiraIssue.type}} + + {{/if}} +
+ + {{this.jiraIssue.key}} + + + ยท + + + {{this.jiraIssue.summary}} + +
+
+
+
+ {{#if this.jiraIssue.priority}} +
+ +
+ {{/if}} + {{#if this.jiraIssue.assignee}} + + {{/if}} + {{#if this.jiraIssue.status}} +
+ {{this.jiraIssue.status}} +
+ {{/if}} +
+ + <:anchor as |dd|> + + + + + <:item as |dd|> + + + {{dd.attrs.label}} + + + + {{else}} + + + Add Jira link + + {{/if}} +
+ +
+ + {{! Plus button }} + + <:header as |rr|> +
+
+ + + +
+
+ +
+ + {{! Resources }} +
+ {{#if (or this.hermesDocuments.length this.externalLinks.length)}} + {{#if this.hermesDocuments.length}} + + {{! Documents }} +
+

+ Documents +

+ +
+
    + {{#each this.hermesDocuments as |document|}} +
  1. + +
    +
    + {{! Avatar }} + + + + + {{! Text }} +
    + + {{! Primary click area }} + + + {{! Title and docNumber }} +
    +

    + + {{document.title}} + + + + {{document.documentNumber}} + + +

    +
    +
    +
    + By + + {{get document.owners 0}} + +
    +
    + {{#if document.summary}} +

    + {{document.summary}} +

    + {{/if}} + + {{! Document tags }} +
    + + + + + + +
    +
    +
    + +
    +
    +
    +
  2. + {{/each}} +
+ {{/if}} + + {{#if this.externalLinks.length}} + {{! External links }} +
+

+ External links +

+ +
+
    + {{#each this.externalLinks as |link i|}} +
  1. + + +
    +
    + +
    +
    +

    + {{link.name}} +

    +
    + {{link.url}} +
    +
    +
    +
    +
    +
  2. + {{/each}} +
+ {{/if}} + {{else}} +
+
+ Nothing here yet +
+
+ Add documents and links using the + button +
+
+ {{/if}} + +
+ Project created + {{time-ago @project.createdTime}} + by + {{@project.creator}} +
+
+
+ +{{#if this.editModalIsShown}} + +{{/if}} diff --git a/web/app/components/project/index.ts b/web/app/components/project/index.ts new file mode 100644 index 000000000..fcfd2f34f --- /dev/null +++ b/web/app/components/project/index.ts @@ -0,0 +1,374 @@ +import { action } from "@ember/object"; +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { + RelatedExternalLink, + RelatedHermesDocument, + RelatedResource, +} from "../related-resources"; +import { RelatedResourceSelector } from "hermes/components/related-resources"; +import { inject as service } from "@ember/service"; +import FetchService from "hermes/services/fetch"; +import { task } from "ember-concurrency"; +import { HermesProject, JiraIssue } from "hermes/types/project"; +import { + ProjectStatus, + projectStatusObjects, +} from "hermes/types/project-status"; +import { assert } from "@ember/debug"; +import FlashMessageService from "ember-cli-flash/services/flash-messages"; +import ConfigService from "hermes/services/config"; + +interface ProjectIndexComponentSignature { + Args: { + project: HermesProject; + }; +} + +export default class ProjectIndexComponent extends Component { + @service("fetch") declare fetchSvc: FetchService; + @service("config") declare configSvc: ConfigService; + @service declare flashMessages: FlashMessageService; + + /** + * The array of possible project statuses. + * Used in the status dropdown. + */ + protected statuses = projectStatusObjects; + + /** + * Locally tracked project attributes. + * Initially set to the project's attributes; + * updated as the user makes changes. + */ + @tracked protected title = this.args.project.title; + @tracked protected description = this.args.project.description; + @tracked protected status = this.args.project.status; + @tracked protected jiraIssue?: JiraIssue = this.args.project.jiraIssue; + @tracked protected hermesDocuments: RelatedHermesDocument[] = + this.args.project.hermesDocuments ?? []; + @tracked protected externalLinks: RelatedExternalLink[] = + this.args.project.externalLinks ?? []; + + /** + * Whether the "edit external link" modal is shown. + */ + @tracked protected editModalIsShown = false; + + /** + * The external link that's currently being edited. + * Used by the modal to display current values and + * run the save action. + */ + @tracked protected resourceToEdit?: RelatedExternalLink; + + /** + * The index of the resource to edit. + * Used to update the resource in the array. + */ + @tracked private resourceToEditIndex?: number; + + /** + * The label for the status dropdown. + * Represents the current status of the project. + */ + protected get statusLabel() { + return this.statuses[this.status].label; + } + + /** + * The icon for the status dropdown. + * Represents the current status of the project. + */ + protected get statusIcon() { + return this.statuses[this.status].icon; + } + + /** + * The related resources object, minimally formatted for a PUT request to the API. + */ + private get formattedRelatedResources(): { + hermesDocuments: Partial[]; + externalLinks: Partial[]; + } { + this.updateSortOrder(); + + const hermesDocuments = this.hermesDocuments.map((doc) => { + return { + ...doc, + googleFileID: doc.googleFileID, + sortOrder: doc.sortOrder, + product: doc.product, + }; + }); + + const externalLinks = this.externalLinks.map((link) => { + return { + name: link.name, + url: link.url, + sortOrder: link.sortOrder, + }; + }); + + return { + externalLinks, + hermesDocuments, + }; + } + + /** + * The action to update the `sortOrder` attribute of + * the resources, based on their position in the array. + * Called when the resource list is saved. + */ + private updateSortOrder() { + this.hermesDocuments.forEach((doc, index) => { + doc.sortOrder = index + 1; + }); + + this.externalLinks.forEach((link, index) => { + link.sortOrder = index + 1 + this.hermesDocuments.length; + }); + } + + /** + * The action to run when the "edit external link" modal is dismissed. + * Hides the modal and resets the local state. + */ + @action protected hideEditModal(): void { + this.editModalIsShown = false; + this.resourceToEdit = undefined; + this.resourceToEditIndex = undefined; + } + + /** + * The action to add a resource to a project. + * Used by the `RelatedResources` component to add a resource. + * Adds the resource to the correct array, then saves the project. + */ + @action protected addResource(resource: RelatedResource): void { + if ("googleFileID" in resource) { + this.addDocument(resource); + } else { + this.addLink(resource); + } + } + + /** + * The action to delete a resource from a project. + * Accessible in the overflow menu of a project resource. + * Removes the resource from the correct array, then saves the project. + */ + @action protected deleteResource(doc: RelatedResource): void { + const cachedDocuments = this.hermesDocuments.slice(); + const cachedLinks = this.externalLinks.slice(); + + if ("googleFileID" in doc) { + this.hermesDocuments.removeObject(doc); + } else { + this.externalLinks.removeObject(doc); + } + void this.saveProjectResources.perform(cachedDocuments, cachedLinks); + } + + /** + * The action to change the project's status. + * Updates the local status, then saves the project. + * Runs when a user selects a new status from the dropdown. + */ + @action protected changeStatus(status: ProjectStatus): void { + this.status = status; + void this.saveProjectInfo.perform("status", status); + } + + /** + * The action to save the project's title. + * Updates the local title, then saves the project. + * Runs when the user accepts the EditableField changes. + */ + @action protected saveTitle(newValue: string): void { + this.title = newValue; + void this.saveProjectInfo.perform("title", newValue); + } + + /** + * The action to save the project's description. + * Updates the local description, then saves the project. + * Runs when the user accepts the EditableField changes. + */ + @action protected saveDescription(newValue: string): void { + this.description = newValue; + void this.saveProjectInfo.perform("description", newValue); + } + + /** + * TODO: Implement this. + * --------------------- + * The placeholder action for adding a Jira object. + * Updates the local Jira object, then saves the project. + */ + @action protected addJiraIssue(): void { + // TODO: implement this + this.jiraIssue = { + key: "HER-123", + url: "https://www.google.com", + priority: "High", + status: "Open", + type: "Bug", + summary: "Vault Data Gathering Initiative: Support", + assignee: "John Dobis", + }; + void this.saveProjectInfo.perform("jiraIssue", this.jiraIssue); + } + + /** + * The action to remove a Jira object from a project. + * Updates the local Jira object, then saves the project. + * Accessible in the overflow menu of a project resource. + */ + @action protected removeJiraIssue(): void { + this.jiraIssue = undefined; + void this.saveProjectInfo.perform("jiraIssue", undefined); + } + + /** + * The action to show the "edit external link" modal. + * Run when a user clicks the "edit" button in the overflow menu. + * Sets the local resource references and shows the modal. + */ + @action protected showEditModal(resource: RelatedResource, index: number) { + this.resourceToEdit = resource as RelatedExternalLink; + this.resourceToEditIndex = index; + this.editModalIsShown = true; + } + + /** + * The action to add a document to a project. + * Adds a resource to the correct array, then saves the project. + */ + @action protected addDocument(resource: RelatedHermesDocument) { + const cachedDocuments = this.hermesDocuments.slice(); + + this.hermesDocuments.unshiftObject(resource); + + void this.saveProjectResources.perform( + cachedDocuments, + this.externalLinks.slice(), + RelatedResourceSelector.HermesDocument, + ); + } + + /** + * The action to add a link to a project. + * Adds a resource to the correct array, then saves the project. + */ + @action protected addLink(resource: RelatedExternalLink) { + const cachedLinks = this.externalLinks.slice(); + + this.externalLinks.unshiftObject(resource); + + void this.saveProjectResources.perform( + this.hermesDocuments.slice(), + cachedLinks, + RelatedResourceSelector.ExternalLink, + ); + } + + @action protected saveExternalLink(link: RelatedExternalLink) { + const cachedLinks = this.externalLinks.slice(); + + assert( + "resourceToEditIndex must exist", + this.resourceToEditIndex !== undefined, + ); + + this.externalLinks[this.resourceToEditIndex] = link; + + // Replacing an individual link doesn't cause the getter + // to recompute, so we manually save the array. + this.externalLinks = this.externalLinks; + + void this.saveProjectResources.perform( + this.hermesDocuments.slice(), + cachedLinks, + this.resourceToEditIndex, + ); + + this.editModalIsShown = false; + this.resourceToEdit = undefined; + this.resourceToEditIndex = undefined; + } + + /** + * The action to save basic project attributes, + * such as title, description, and status. + */ + protected saveProjectInfo = task( + async (key?: string, newValue?: string | JiraIssue) => { + try { + const valueToSave = key + ? { [key]: newValue } + : this.formattedRelatedResources; + await this.fetchSvc.fetch(`/api/v1/projects/${this.args.project.id}`, { + method: "PATCH", + body: JSON.stringify(valueToSave), + }); + } catch (e: unknown) { + this.flashMessages.add({ + title: "Unable to save", + message: (e as any).message, + type: "critical", + timeout: 10000, + extendedTimeout: 1000, + }); + } + }, + ); + + /** + * The task to save the document's related resources. + * Creates a PUT request to the DB and conditionally triggers + * the resource-highlight animation. + */ + protected saveProjectResources = task( + async ( + cachedDocuments, + cachedLinks, + elementSelectorToHighlight?: string | number, + ) => { + if (elementSelectorToHighlight) { + // void this.animateHighlight.perform(elementSelectorToHighlight); + } + + try { + await this.fetchSvc.fetch( + `/api/${this.configSvc.config.api_version}/projects/${this.args.project.id}/related-resources`, + { + method: "PUT", + body: JSON.stringify(this.formattedRelatedResources), + headers: { + "Content-Type": "application/json", + }, + }, + ); + } catch (e: unknown) { + this.externalLinks = cachedLinks; + this.hermesDocuments = cachedDocuments; + + this.flashMessages.add({ + title: "Unable to save resource", + message: (e as any).message, + type: "critical", + sticky: true, + extendedTimeout: 1000, + }); + } + }, + ); +} + +declare module "@glint/environment-ember-loose/registry" { + export default interface Registry { + Project: typeof ProjectIndexComponent; + } +} diff --git a/web/app/components/project/resource.hbs b/web/app/components/project/resource.hbs new file mode 100644 index 000000000..536522639 --- /dev/null +++ b/web/app/components/project/resource.hbs @@ -0,0 +1,6 @@ +
+ + {{yield}} + + +
diff --git a/web/app/components/project/resource.ts b/web/app/components/project/resource.ts new file mode 100644 index 000000000..1f8fbdf48 --- /dev/null +++ b/web/app/components/project/resource.ts @@ -0,0 +1,20 @@ +import Component from "@glimmer/component"; +import { OverflowItem } from "hermes/components/overflow-menu"; + +interface ProjectResourceComponentSignature { + Element: HTMLDivElement; + Args: { + overflowMenuItems: Record; + }; + Blocks: { + default: []; + }; +} + +export default class ProjectResourceComponent extends Component {} + +declare module "@glint/environment-ember-loose/registry" { + export default interface Registry { + "Project::Resource": typeof ProjectResourceComponent; + } +} diff --git a/web/app/components/related-resources.ts b/web/app/components/related-resources.ts index bdb7b24f4..007839729 100644 --- a/web/app/components/related-resources.ts +++ b/web/app/components/related-resources.ts @@ -13,6 +13,11 @@ import Ember from "ember"; export type RelatedResource = RelatedExternalLink | RelatedHermesDocument; +export enum RelatedResourceSelector { + ExternalLink = ".external-resource", + HermesDocument = ".hermes-document", +} + export interface RelatedExternalLink { name: string; url: string; diff --git a/web/app/components/related-resources/add/fallback-external-resource.hbs b/web/app/components/related-resources/add/fallback-external-resource.hbs index 44b6c355d..e31432bb5 100644 --- a/web/app/components/related-resources/add/fallback-external-resource.hbs +++ b/web/app/components/related-resources/add/fallback-external-resource.hbs @@ -6,6 +6,7 @@
li:not(:first-child) { + @apply border-t border-t-color-border-faint; + } + } + + .project-resource { + @apply relative; + + &:hover, + &:focus-within { + .overflow-button-container { + @apply visible; + } + } + + .title { + @apply text-display-200 font-semibold text-color-foreground-strong; + } + + .owner { + @apply text-color-foreground-faint; + } + + .overflow-button-container { + @apply absolute right-1 top-1; + } + } +} diff --git a/web/app/templates/authenticated/projects/project.hbs b/web/app/templates/authenticated/projects/project.hbs index 4907ce455..d74f90db6 100644 --- a/web/app/templates/authenticated/projects/project.hbs +++ b/web/app/templates/authenticated/projects/project.hbs @@ -1 +1,4 @@ {{page-title this.model.title}} +{{set-body-class "project-screen"}} + + diff --git a/web/app/types/project-status.ts b/web/app/types/project-status.ts index 2f426b456..3ee06f2ed 100644 --- a/web/app/types/project-status.ts +++ b/web/app/types/project-status.ts @@ -3,3 +3,24 @@ export enum ProjectStatus { Completed = "completed", Archived = "archived", } + +export type ProjectStatusObject = { + label: string; + icon: string; +}; + +export const projectStatusObjects: Record = + { + [ProjectStatus.Active]: { + label: "Active", + icon: "check-circle", + }, + [ProjectStatus.Completed]: { + label: "Completed", + icon: "check-circle", + }, + [ProjectStatus.Archived]: { + label: "Archived", + icon: "archive", + }, + }; diff --git a/web/app/types/project.d.ts b/web/app/types/project.d.ts index eae42d1c6..4b792502a 100644 --- a/web/app/types/project.d.ts +++ b/web/app/types/project.d.ts @@ -23,7 +23,7 @@ export interface HermesProject { jiraIssueID?: string; jiraIssue?: JiraIssue; creator: string; - createdDate: number; + createdTime: number; modifiedTime: number; externalLinks?: RelatedExternalLink[]; } diff --git a/web/mirage/config.ts b/web/mirage/config.ts index 2970c6bf2..c7a66e9a8 100644 --- a/web/mirage/config.ts +++ b/web/mirage/config.ts @@ -203,6 +203,18 @@ export default function (mirageConfig) { return new Response(200, {}, project.attrs); }); + // Update a project. + this.patch("/projects/:project_id", (schema, request) => { + const project = schema.projects.findBy({ + id: request.params.project_id, + }); + + if (project) { + project.update(JSON.parse(request.requestBody)); + return new Response(200, {}, project.attrs); + } + }); + /** * Fetch a project's related resources. * Since Mirage doesn't yet know the relationship between projects and resources, @@ -217,7 +229,7 @@ export default function (mirageConfig) { }); // Fetch a project's related resources - this.put("/projects/:project_id", (schema, request) => { + this.put("/projects/:project_id/related-resources", (schema, request) => { let project = schema.projects.findBy({ id: request.params.project_id, }); diff --git a/web/mirage/factories/project.ts b/web/mirage/factories/project.ts index c12cef856..0cdd682d5 100644 --- a/web/mirage/factories/project.ts +++ b/web/mirage/factories/project.ts @@ -4,8 +4,8 @@ import { HermesProject } from "hermes/types/project"; export default Factory.extend({ id: (i: number) => i, title: (i: number) => `Test Project ${i}`, - createdDate: 1, - modifiedDate: 1, + createdTime: 1, + modifiedTime: 1, creator: "testuser@example.com", status: "active", diff --git a/web/tests/acceptance/authenticated/projects/project-test.ts b/web/tests/acceptance/authenticated/projects/project-test.ts index 0fa16c318..9b9dae165 100644 --- a/web/tests/acceptance/authenticated/projects/project-test.ts +++ b/web/tests/acceptance/authenticated/projects/project-test.ts @@ -1,26 +1,520 @@ import { MirageTestContext, setupMirage } from "ember-cli-mirage/test-support"; import { authenticateSession } from "ember-simple-auth/test-support"; -import { module, test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { module, test, todo } from "qunit"; +import { click, fillIn, visit, waitFor } from "@ember/test-helpers"; import { getPageTitle } from "ember-page-title/test-support"; import { setupApplicationTest } from "ember-qunit"; +import { ProjectStatus } from "hermes/types/project-status"; + +const TITLE = "[data-test-project-title]"; +const TITLE_BUTTON = `${TITLE} button`; +const TITLE_INPUT = `${TITLE} textarea`; +const TITLE_ERROR = `${TITLE} .hds-form-error`; + +const DESCRIPTION = "[data-test-project-description]"; +const DESCRIPTION_BUTTON = `${DESCRIPTION} button`; +const DESCRIPTION_INPUT = `${DESCRIPTION} textarea`; + +const SAVE_EDITABLE_FIELD_BUTTON = ".editable-field [data-test-save-button]"; + +const ADD_RESOURCE_BUTTON = "[data-test-add-project-resource-button]"; + +const RELATED_LINK_MODAL = "[data-test-add-or-edit-external-resource-modal]"; +const RELATED_LINK_TITLE_INPUT = + "[data-test-external-resource-form-title-input]"; +const RELATED_LINK_URL_INPUT = "[data-test-external-resource-url-input]"; + +const RELATED_LINK_SAVE_BUTTON = `${RELATED_LINK_MODAL} [data-test-save-button]`; + +const ADD_PROJECT_RESOURCE_MODAL = "[data-test-add-related-resource-modal]"; +const MODAL_SEARCH_INPUT = "[data-test-related-resources-search-input]"; +const ADD_DOCUMENT_OPTION = ".related-document-option"; + +const EMPTY_BODY = "[data-test-empty-body]"; + +const DOCUMENTS_HEADER = "[data-test-documents-header]"; +const DOCUMENT_COUNT = "[data-test-document-count]"; +const DOCUMENT_LIST = "[data-test-document-list]"; + +const EXTERNAL_LINKS_HEADER = "[data-test-external-links-header]"; +const EXTERNAL_LINK_COUNT = "[data-test-external-link-count]"; +const EXTERNAL_LINK_LIST = "[data-test-external-link-list]"; + +const DOCUMENT_LIST_ITEM = "[data-test-document-list-item]"; +const OVERFLOW_MENU_BUTTON = "[data-test-overflow-menu-button]"; +const OVERFLOW_MENU_EDIT = "[data-test-overflow-menu-action='edit']"; +const OVERFLOW_MENU_REMOVE = "[data-test-overflow-menu-action='remove']"; + +const DOCUMENT_LINK = "[data-test-document-link]"; +const DOCUMENT_TITLE = "[data-test-document-title]"; +const DOCUMENT_SUMMARY = "[data-test-document-summary]"; +const DOCUMENT_NUMBER = "[data-test-document-number]"; +const DOCUMENT_OWNER_NAME = "[data-test-document-owner-name]"; +const DOCUMENT_OWNER_AVATAR = "[data-test-document-owner-avatar]"; +const DOCUMENT_STATUS = "[data-test-document-status]"; +const DOCUMENT_TYPE = "[data-test-document-type]"; + +const FALLBACK_RELATED_LINK = "[data-test-add-fallback-external-resource]"; +const FALLBACK_RELATED_LINK_TITLE_INPUT = `${FALLBACK_RELATED_LINK} [data-test-title-input]`; +const FALLBACK_RELATED_LINK_SUBMIT_BUTTON = `${FALLBACK_RELATED_LINK} [data-test-submit-button]`; + +const EXTERNAL_LINK = "[data-test-related-link]"; + +const STATUS_TOGGLE = "[data-test-project-status-toggle]"; +const COPY_URL_BUTTON = "[data-test-copy-url-button]"; + +const ADD_JIRA_BUTTON = "[data-test-add-jira-button]"; + +const JIRA_OVERFLOW_BUTTON = "[data-test-jira-overflow-button]"; +const JIRA_LINK = "[data-test-jira-link]"; +const JIRA_PRIORITY_ICON = "[data-test-jira-priority-icon]"; +const JIRA_ASSIGNEE_AVATAR = "[data-test-jira-assignee-avatar]"; +const JIRA_STATUS = "[data-test-jira-status]"; +const JIRA_TYPE_ICON = "[data-test-jira-type-icon]"; +const JIRA_KEY = "[data-test-jira-key]"; +const JIRA_SUMMARY = "[data-test-jira-summary]"; + +const ACTIVE_STATUS_ACTION = "[data-test-status-action='active']"; +const COMPLETED_STATUS_ACTION = "[data-test-status-action='completed']"; +const ARCHIVED_STATUS_ACTION = "[data-test-status-action='archived']"; interface AuthenticatedProjectsProjectRouteTestContext extends MirageTestContext {} + module("Acceptance | authenticated/projects/project", function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); - hooks.beforeEach(async function () { + hooks.beforeEach(async function ( + this: AuthenticatedProjectsProjectRouteTestContext, + ) { await authenticateSession({}); - }); - - test("the page title is correct", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { this.server.create("project", { id: 1, title: "Test Project", }); + }); + + test("the page title is correct", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { await visit("/projects/1"); assert.equal(getPageTitle(), "Test Project | Hermes"); }); + + test("it renders correct empty state", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + let project = this.server.schema.projects.first(); + + project.update({ + jiraIssue: undefined, + hermesDocuments: undefined, + }); + + project = project.attrs; + + await visit("/projects/1"); + + assert.dom(TITLE).hasText("Test Project"); + assert.dom(DESCRIPTION).hasText("Add a description"); + + assert.dom(ADD_JIRA_BUTTON).exists(); + + assert.dom(DOCUMENTS_HEADER).doesNotExist(); + assert.dom(DOCUMENT_LIST).doesNotExist(); + + assert.dom(EXTERNAL_LINKS_HEADER).doesNotExist(); + assert.dom(EXTERNAL_LINK_LIST).doesNotExist(); + + assert.dom(EMPTY_BODY).exists(); + }); + + test("it renders the correct filled-in state", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + const docTitle = "Foo bar"; + const docSummary = "Baz qux"; + const docStatus = "Approved"; + const docType = "PRD"; + const docNumber = "LAB-023"; + const docOwner = "foo@bar.com"; + const docOwnerPhotoURL = "#foo"; + const docProduct = "Terraform"; + + this.server.create("document", { + title: docTitle, + summary: docSummary, + status: docStatus, + docType, + docNumber, + owners: [docOwner], + ownerPhotos: [docOwnerPhotoURL], + product: docProduct, + }); + + const document = this.server.schema.document.first().attrs; + + const relatedDocument = { + ...document, + googleFileID: document.objectID, + documentType: document.docType, + documentNumber: document.docNumber, + }; + + const externalLinkName = "Foo"; + const externalLinkURL = "https://hashicorp.com"; + + this.server.create("related-external-link", { + name: externalLinkName, + url: externalLinkURL, + }); + + const externalLink = this.server.schema.relatedExternalLinks.first().attrs; + + const project = this.server.schema.projects.first(); + + const projectTitle = "Test Project Title"; + const projectDescription = "Test project description"; + const projectStatus: ProjectStatus = ProjectStatus.Active; + const projectStatusLabel = + projectStatus.charAt(0).toUpperCase() + projectStatus.slice(1); + + const jiraKey = "HER-123"; + const jiraURL = "https://hashicorp.com"; + const jiraPriority = "High"; + const jiraStatus = "Open"; + const jiraSummary = "Baz Foo"; + const jiraAssignee = "foo@bar.com"; + + project.update({ + title: projectTitle, + description: projectDescription, + status: projectStatus, + hermesDocuments: [relatedDocument], + externalLinks: [externalLink], + jiraIssue: { + key: jiraKey, + url: jiraURL, + priority: jiraPriority, + status: jiraStatus, + type: "any", + summary: jiraSummary, + assignee: jiraAssignee, + }, + }); + + // Populate the related resources modal + this.server.createList("document", 4); + + await visit("/projects/1"); + + assert.dom(DOCUMENT_LINK).hasAttribute("href", "/document/doc-0"); + + assert.dom(DOCUMENT_TITLE).containsText(docTitle); + + assert.dom(DOCUMENT_NUMBER).containsText(docNumber); + assert.dom(DOCUMENT_SUMMARY).containsText(docSummary); + + assert + .dom(DOCUMENT_OWNER_AVATAR) + .hasAttribute("href", "/documents?owners=%5B%22foo%40bar.com%22%5D"); + + assert + .dom(DOCUMENT_OWNER_NAME) + .containsText(docOwner) + .hasAttribute("href", "/documents?owners=%5B%22foo%40bar.com%22%5D"); + + assert + .dom(DOCUMENT_TYPE) + .containsText(docType) + .hasAttribute("href", "/documents?docType=%5B%22PRD%22%5D"); + + assert + .dom(DOCUMENT_STATUS) + .containsText(docStatus) + .hasAttribute("href", "/documents?status=%5B%22Approved%22%5D"); + + assert + .dom(EXTERNAL_LINK) + .containsText(externalLinkName) + .hasAttribute("href", externalLinkURL); + + assert.dom(ADD_RESOURCE_BUTTON).exists(); + assert.dom(COPY_URL_BUTTON).exists(); + + assert.dom(STATUS_TOGGLE).hasText(projectStatusLabel); + + assert.dom(JIRA_LINK).hasAttribute("href", jiraURL); + assert.dom(JIRA_KEY).hasText(jiraKey); + assert.dom(JIRA_SUMMARY).hasText(jiraSummary); + assert.dom(JIRA_STATUS).hasText(jiraStatus); + assert.dom(JIRA_TYPE_ICON).exists(); + assert.dom(JIRA_PRIORITY_ICON).exists(); + + assert + .dom(JIRA_ASSIGNEE_AVATAR) + .hasAttribute("data-test-assignee", jiraAssignee) + .hasText(jiraAssignee.charAt(0)); + + assert.dom(JIRA_OVERFLOW_BUTTON).exists(); + }); + + test("you can edit a project title", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + await visit("/projects/1"); + + assert.dom(TITLE).hasText("Test Project"); + + await click(TITLE_BUTTON); + await fillIn(TITLE_INPUT, "New Project Title"); + await click(SAVE_EDITABLE_FIELD_BUTTON); + + assert.dom(TITLE).hasText("New Project Title"); + + const project = this.server.schema.projects.first().attrs; + + assert.equal(project.title, "New Project Title"); + }); + + test("you can edit a project description", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + await visit("/projects/1"); + + assert.dom(DESCRIPTION).hasText("Add a description"); + + await click(DESCRIPTION_BUTTON); + await fillIn(DESCRIPTION_INPUT, "Foo"); + await click(SAVE_EDITABLE_FIELD_BUTTON); + + assert.dom(DESCRIPTION).hasText("Foo"); + + const project = this.server.schema.projects.first().attrs; + assert.equal(project.description, "Foo"); + }); + + test("you can add a document to a project", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + const docTitle = "Foo Bar"; + + this.server.create("document", { + title: docTitle, + }); + + const project = this.server.schema.projects.first(); + + project.update({ + hermesDocuments: [], + }); + + await visit("/projects/1"); + + assert.dom(DOCUMENTS_HEADER).doesNotExist(); + + await click(ADD_RESOURCE_BUTTON); + + await waitFor(ADD_PROJECT_RESOURCE_MODAL); + assert.dom(ADD_PROJECT_RESOURCE_MODAL).exists(); + + await click(ADD_DOCUMENT_OPTION); + + assert.dom(ADD_PROJECT_RESOURCE_MODAL).doesNotExist(); + + assert.dom(DOCUMENTS_HEADER).exists(); + assert.dom(DOCUMENT_COUNT).containsText("1"); + assert.dom(DOCUMENT_LIST_ITEM).exists({ count: 1 }); + + const projectDocuments = + this.server.schema.projects.first().attrs.hermesDocuments; + + assert.equal(projectDocuments.length, 1); + assert.equal(projectDocuments[0].title, docTitle); + }); + + test("you can remove a document from a project", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + await visit("/projects/1"); + + assert.dom(DOCUMENT_LIST_ITEM).exists({ count: 1 }); + + await click(OVERFLOW_MENU_BUTTON); + await click(OVERFLOW_MENU_REMOVE); + + assert.dom(DOCUMENT_LIST_ITEM).doesNotExist(); + + const projectDocuments = + this.server.schema.projects.first().attrs.hermesDocuments; + + assert.equal(projectDocuments.length, 0); + }); + + test("you can add external links to a project", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + const project = this.server.schema.projects.first(); + + const linkTitle = "Foo"; + const linkURL = "https://foo.com"; + + project.update({ + externalLinks: [], + }); + + await visit("/projects/1"); + + assert.dom(EXTERNAL_LINK).doesNotExist(); + + await click(ADD_RESOURCE_BUTTON); + + assert.dom(ADD_PROJECT_RESOURCE_MODAL).exists(); + + await fillIn(MODAL_SEARCH_INPUT, linkURL); + + await waitFor(FALLBACK_RELATED_LINK_TITLE_INPUT); + + await fillIn(FALLBACK_RELATED_LINK_TITLE_INPUT, linkTitle); + + await click(FALLBACK_RELATED_LINK_SUBMIT_BUTTON); + + assert.dom(RELATED_LINK_MODAL).doesNotExist(); + + assert.dom(EXTERNAL_LINKS_HEADER).exists(); + assert.dom(EXTERNAL_LINK_COUNT).containsText("1"); + assert.dom(EXTERNAL_LINK).exists({ count: 1 }); + + assert.dom(EXTERNAL_LINK).containsText(linkTitle); + assert.dom(EXTERNAL_LINK).containsText(linkURL); + assert.dom(EXTERNAL_LINK).hasAttribute("href", linkURL); + }); + + test("you can edit a project's external links", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + const project = this.server.schema.projects.first(); + + this.server.create("related-external-link", { + name: "Foo", + url: "https://foo.com", + }); + + const externalLink = this.server.schema.relatedExternalLinks.first().attrs; + + project.update({ + // Remove docs for easier targeting + hermesDocuments: [], + externalLinks: [externalLink], + }); + + await visit("/projects/1"); + + assert.dom(EXTERNAL_LINK).exists({ count: 1 }); + + await click(OVERFLOW_MENU_BUTTON); + await click(OVERFLOW_MENU_EDIT); + + const linkTitle = "Bar"; + const linkURL = "https://bar.com"; + + await fillIn(RELATED_LINK_TITLE_INPUT, linkTitle); + await fillIn(RELATED_LINK_URL_INPUT, linkURL); + + await click(RELATED_LINK_SAVE_BUTTON); + + assert.dom(EXTERNAL_LINK).exists({ count: 1 }); + assert.dom(EXTERNAL_LINK).containsText(linkTitle); + assert.dom(EXTERNAL_LINK).containsText(linkURL); + assert.dom(EXTERNAL_LINK).hasAttribute("href", linkURL); + + const projectLinks = + this.server.schema.projects.first().attrs.externalLinks; + + const projectLink = projectLinks[0]; + + assert.equal(projectLink.name, linkTitle); + assert.equal(projectLink.url, linkURL); + }); + + test("you can delete a project's external links", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + const project = this.server.schema.projects.first(); + + this.server.create("related-external-link", { + name: "Foo", + url: "https://foo.com", + }); + + const externalLink = this.server.schema.relatedExternalLinks.first().attrs; + + project.update({ + // Remove docs for easier targeting + hermesDocuments: [], + externalLinks: [externalLink], + }); + + await visit("/projects/1"); + + assert.dom(EXTERNAL_LINK).exists({ count: 1 }); + + await click(OVERFLOW_MENU_BUTTON); + await click(OVERFLOW_MENU_REMOVE); + + assert.dom(EXTERNAL_LINK).doesNotExist(); + + const projectLinks = + this.server.schema.projects.first().attrs.externalLinks; + + assert.equal(projectLinks.length, 0); + }); + + test("you can't save an empty project title", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + await visit("/projects/1"); + + assert.dom(TITLE).hasText("Test Project"); + + await click(TITLE_BUTTON); + + await fillIn(TITLE_INPUT, ""); + await click(SAVE_EDITABLE_FIELD_BUTTON); + + assert.dom(TITLE_ERROR).exists(); + assert.dom(TITLE_INPUT).exists("the field remains in edit mode"); + }); + + test("you can change a project's status", async function (this: AuthenticatedProjectsProjectRouteTestContext, assert) { + await visit("/projects/1"); + + assert.dom(STATUS_TOGGLE).hasText("Active"); + + await click(STATUS_TOGGLE); + await click(COMPLETED_STATUS_ACTION); + + assert.dom(STATUS_TOGGLE).hasText("Completed"); + + let project = this.server.schema.projects.first().attrs; + + assert.equal(project.status, ProjectStatus.Completed); + + await click(STATUS_TOGGLE); + await click(ARCHIVED_STATUS_ACTION); + + assert.dom(STATUS_TOGGLE).hasText("Archived"); + + project = this.server.schema.projects.first().attrs; + + assert.equal(project.status, ProjectStatus.Archived); + + await click(STATUS_TOGGLE); + await click(ACTIVE_STATUS_ACTION); + + assert.dom(STATUS_TOGGLE).hasText("Active"); + + project = this.server.schema.projects.first().attrs; + + assert.equal(project.status, ProjectStatus.Active); + }); + + todo( + "you can add a jira link", + async function ( + this: AuthenticatedProjectsProjectRouteTestContext, + assert, + ) { + assert.true(false); + }, + ); + + todo( + "you can copy a project's URL", + async function ( + this: AuthenticatedProjectsProjectRouteTestContext, + assert, + ) { + assert.true(false); + }, + ); });