This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7415-69c7391a5f613b165ce7276f81811a98de48c442 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 97dac3c2db0055a5bb98a23d6741ace3fc106742 Author: Xinyuan Lin <[email protected]> AuthorDate: Sat Aug 8 00:24:58 2026 -0700 test(frontend): render the project list item's permission and description rules (#7415) ### What changes were proposed in this PR? `UserProjectListItemComponent` decides in its template what a viewer may touch, and none of it was rendered — the existing specs call the save and colour methods directly. Adds 8 tests. The one that matters most is the `editable` gating: a project the viewer only holds READ on must not be offered the rename, add-description, share or delete controls, and that decision lives entirely in two `*ngIf="editable"` guards plus one on the action list. Also covered: the name/edit-input swap, the description starting collapsed and expanding on request, the `trim()` guard that stops a whitespace-only description rendering an empty expander, the character counter, the save icon appearing only once the text actually differs, and the creation-date format. **Verified by mutation**, all reverted (template diff empty): | Mutation | Result | |---|---| | show the rename button to a read-only viewer | red | | show the share/delete actions to a read-only viewer | red | | invert the name / edit-input branch | red | | drop the collapse guard | red | | drop the whitespace `trim()` guard | red | | always show the save icon | red | | count characters against the max instead of the text | red | | change the creation-date format | red | Two things worth recording: - `MarkdownModule.forRoot()` joins the TestBed. An expanded description renders a `<markdown>` element, and no existing test reached that path, so `MarkdownService` had never been needed. - `descriptionCollapsed` defaults to **true**. My first version of the collapse test asserted the opposite and failed, which also revealed that the whitespace test would have passed vacuously — collapsed hides the block regardless. It now expands first, so the `trim()` guard is the only thing left doing the work. No production file is touched. ### Any related issues, documentation, discussions? Closes #7412 ### How was this PR tested? ``` npx ng test --watch=false --include="**/user-project-list-item.component.spec.ts" ``` ``` Test Files 1 passed (1) Tests 18 passed (18) ``` 8 new on top of the existing 10. `yarn format:ci` passes. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --- .../user-project-list-item.component.spec.ts | 116 ++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts index 2263f8553d..f2198854b1 100644 --- a/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-project/user-project-list-item/user-project-list-item.component.spec.ts @@ -31,7 +31,9 @@ import { StubUserService } from "../../../../../common/service/user/stub-user.se import { UserService } from "../../../../../common/service/user/user.service"; import { commonTestProviders } from "../../../../../common/testing/test-utils"; import { ShareAccessComponent } from "../../share-access/share-access.component"; +import { DatePipe } from "@angular/common"; import { of } from "rxjs"; +import { MarkdownModule } from "ngx-markdown"; // UserProjectListItemComponent is rooted at <nz-list-item>; instantiating it // outside an <nz-list> host throws "No provider found for NzListComponent". @@ -71,7 +73,8 @@ describe("UserProjectListItemComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TestHostComponent, HttpClientTestingModule], + // MarkdownModule.forRoot() backs the <markdown> element in an expanded description. + imports: [TestHostComponent, HttpClientTestingModule, MarkdownModule.forRoot()], providers: [ NotificationService, UserProjectService, @@ -218,4 +221,115 @@ describe("UserProjectListItemComponent", () => { expect(refreshSpy).toHaveBeenCalled(); }); }); + /** + * The list item decides in its template what a viewer is allowed to touch and how much of a long + * description to show. The specs above call the save/colour methods directly, so none of the + * rendered gating had been pinned. + */ + describe("rendered item", () => { + /** Re-renders the host with the given entry/editable combination. */ + function render(over: Partial<DashboardProject> = {}, editable = true): HTMLElement { + hostFixture.componentInstance.entry = { ...testProject, ...over }; + hostFixture.componentInstance.editable = editable; + hostFixture.detectChanges(); + return hostFixture.nativeElement as HTMLElement; + } + + it("shows the project name and its creation date", () => { + const el = render({ name: "quarterly", creationTime: januaryFirst1970 }); + + expect(el.textContent).toContain("quarterly"); + // Expected value is formatted here with the same pipe and format string, so the assertion + // pins both the yyyy-MM-dd HH:mm format and the timestamp it was given, without hard-coding + // a literal that would only hold in one timezone. + const expected = new DatePipe("en-US").transform(januaryFirst1970, "yyyy-MM-dd HH:mm"); + expect(el.querySelector("nz-list-item-meta-description p")?.textContent?.trim()).toBe(`Created: ${expected}`); + }); + + it("hides every editing control from a read-only viewer", () => { + // accessLevel READ reaches this component as editable=false; if the template ignored it the + // viewer would be shown share and delete buttons for a project they cannot change. + const el = render({}, false); + + expect(el.querySelector(".edit-name-icon")).toBeNull(); + expect(el.querySelector(".edit-description-icon")).toBeNull(); + expect(el.querySelector("ul[nz-list-item-actions]")).toBeNull(); + }); + + it("offers the editing controls to a viewer with write access", () => { + const el = render({}, true); + + expect(el.querySelector(".edit-name-icon")).not.toBeNull(); + expect(el.querySelector(".edit-description-icon")).not.toBeNull(); + // Share and delete both live in that list; count the buttons rather than just the container + // (nz-list-item-action renders as an <li>, so the element selector finds nothing). + expect(el.querySelectorAll("ul[nz-list-item-actions] button").length).toBe(2); + }); + + it("swaps the name for an input once the name is being edited", () => { + render(); + expect(hostFixture.nativeElement.querySelector("nz-list-item-meta-title input")).toBeNull(); + + component.editingName = true; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector("nz-list-item-meta-title input")).not.toBeNull(); + }); + + it("starts with the description collapsed and expands it on request", () => { + // descriptionCollapsed defaults to true, so a list of projects stays compact until the user + // opens one. + const el = render({ description: "a long description" }); + expect(el.querySelector(".description-container")).toBeNull(); + + component.descriptionCollapsed = false; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".description-container")).not.toBeNull(); + }); + + it("shows no description block when the description is only whitespace", () => { + // Expanded, so the trim() guard is the only thing left to hide it: a whitespace-only + // description would otherwise render an empty expander with nothing in it. + render({ description: " " }); + component.descriptionCollapsed = false; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".description-container")).toBeNull(); + }); + + it("counts the characters typed into the description editor", () => { + render({ description: "abc" }); + component.editingDescription = true; + hostFixture.detectChanges(); + + const count = hostFixture.nativeElement.querySelector(".character-count")!; + expect(count.textContent?.trim()).toBe(`3/${component.MAX_PROJECT_DESCRIPTION_CHAR_COUNT}`); + + // It must follow what is in the box, not the value the description started at. + const textarea = hostFixture.nativeElement.querySelector("textarea")!; + textarea.value = "abcdef"; + textarea.dispatchEvent(new Event("input")); + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".character-count")!.textContent?.trim()).toBe( + `6/${component.MAX_PROJECT_DESCRIPTION_CHAR_COUNT}` + ); + }); + + it("offers the save button only once the description has actually changed", () => { + render({ description: "abc" }); + component.editingDescription = true; + hostFixture.detectChanges(); + const textarea = hostFixture.nativeElement.querySelector("textarea")!; + + expect(hostFixture.nativeElement.querySelector(".ant-input-clear-icon")).toBeNull(); + + textarea.value = "abcd"; + textarea.dispatchEvent(new Event("input")); + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector(".ant-input-clear-icon")).not.toBeNull(); + }); + }); });
