diff --git a/src/components/PropertyField/PropertyDropdownField.test.ts b/src/components/PropertyField/PropertyDropdownField.test.ts index 6a833e4bc..3f10dd812 100644 --- a/src/components/PropertyField/PropertyDropdownField.test.ts +++ b/src/components/PropertyField/PropertyDropdownField.test.ts @@ -22,4 +22,26 @@ describe("PropertyDropdownField", () => { expect(renderOptions).not.toHaveBeenCalled(); }); + + it("renders disabled options as unavailable", () => { + const markup = renderToStaticMarkup( + React.createElement(PropertyDropdownField, { + value: "open", + label: "Open", + icon: null, + active: true, + searchable: false, + options: [ + { value: "open", label: "Open", disabled: true }, + { value: "closed", label: "Closed" }, + ], + dataTestId: "status", + }) + ); + + expect(markup).toMatch( + /data-testid="status-option-open"[^>]*disabled=""[^>]*aria-disabled="true"/ + ); + expect(markup).toContain('data-testid="status-option-closed"'); + }); }); diff --git a/src/components/PropertyField/PropertyDropdownField.tsx b/src/components/PropertyField/PropertyDropdownField.tsx index 3b58d8587..b1429917c 100644 --- a/src/components/PropertyField/PropertyDropdownField.tsx +++ b/src/components/PropertyField/PropertyDropdownField.tsx @@ -20,6 +20,7 @@ export interface PropertyDropdownOption { label: string; icon?: React.ReactNode; iconColor?: string; + disabled?: boolean; } export type PropertyDropdownPlacement = "inline" | "portal"; @@ -223,6 +224,7 @@ export function PropertyDropdownField({ iconColor={option.iconColor} label={option.label} isSelected={option.value === value} + disabled={option.disabled} onClick={() => handleSelect(option.value)} dataTestId={ dataTestId ? `${dataTestId}-option-${option.value}` : undefined diff --git a/src/components/PropertyField/PropertyFieldEditable.tsx b/src/components/PropertyField/PropertyFieldEditable.tsx index cfd0ccf77..c286465dd 100644 --- a/src/components/PropertyField/PropertyFieldEditable.tsx +++ b/src/components/PropertyField/PropertyFieldEditable.tsx @@ -347,6 +347,7 @@ export interface OptionProps { iconColor?: string; label: string; isSelected?: boolean; + disabled?: boolean; onClick: () => void; children?: React.ReactNode; /** Stable selector for rendered UI tests. */ @@ -358,6 +359,7 @@ export const Option: React.FC = ({ iconColor, label, isSelected, + disabled = false, onClick, children, dataTestId, @@ -367,13 +369,16 @@ export const Option: React.FC = ({ data-testid={dataTestId} className={[ DROPDOWN_CLASSES.item, - DROPDOWN_CLASSES.itemHover, + !disabled && DROPDOWN_CLASSES.itemHover, "w-full justify-between text-left", isSelected && DROPDOWN_CLASSES.itemSelected, + disabled && DROPDOWN_CLASSES.itemDisabled, ] .filter(Boolean) .join(" ")} onClick={onClick} + disabled={disabled} + aria-disabled={disabled} > {children ? ( <> diff --git a/src/hooks/ui/layout/useElementDimensions.ts b/src/hooks/ui/layout/useElementDimensions.ts index f3b2dfd6b..f65ba34a1 100644 --- a/src/hooks/ui/layout/useElementDimensions.ts +++ b/src/hooks/ui/layout/useElementDimensions.ts @@ -28,6 +28,8 @@ export interface ElementDimensions { export interface UseElementDimensionsOptions { /** What to measure: 'width', 'height', or 'both' */ dimension?: DimensionType; + /** Disable measurement and listener ownership while the element is absent. */ + enabled?: boolean; /** Additional dependency to trigger re-measurement */ deps?: unknown[]; } @@ -70,7 +72,7 @@ export function useElementDimensions( ref: RefObject, options: UseElementDimensionsOptions = {} ): number | ElementDimensions { - const { dimension = "both", deps = [] } = options; + const { dimension = "both", enabled = true, deps = [] } = options; const [dimensions, setDimensions] = useState({ width: 0, @@ -78,6 +80,8 @@ export function useElementDimensions( }); useIsomorphicLayoutEffect(() => { + if (!enabled) return; + const measureDimensions = () => { // Handle nested ref objects const element = @@ -130,7 +134,7 @@ export function useElementDimensions( window.removeEventListener("resize", measureDimensions); resizeObserver?.disconnect(); }; - }, [ref, ...deps]); + }, [ref, enabled, ...deps]); // Return based on requested dimension if (dimension === "width") return dimensions.width; diff --git a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts index 77d1b8e04..3e2b58099 100644 --- a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts @@ -162,6 +162,8 @@ describe("mapGitHubIssueToThreadWorkItem", () => { `data-testid="work-item-property-status-${issue.html_url}"` ); expect(markup).toContain('data-testid="github-issue-inline-composer"'); + expect(markup).toContain('data-testid="work-item-thread-floating-footer"'); + expect(markup).toContain("padding-bottom:240px"); expect(markup).not.toContain( 'data-testid="work-item-thread-secondary-navigation"' ); @@ -183,7 +185,7 @@ describe("mapGitHubIssueToThreadWorkItem", () => { ); expect( markup.match(/data-scroll-trail-target/g)?.length - ).toBeGreaterThanOrEqual(4); + ).toBeGreaterThanOrEqual(3); }); it("toggles external assignees without duplicating login casing", () => { diff --git a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.tsx b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.tsx index caa2da6cb..601eb6b3f 100644 --- a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.tsx +++ b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import type { GitHubIssue, @@ -73,6 +74,7 @@ const GitHubIssueThreadSurface: React.FC = ({ interaction, assigneeConfig, }) => { + const { t } = useTranslation("common"); const workItem = useMemo( () => mapGitHubIssueToThreadWorkItem(issue), [issue] @@ -99,8 +101,16 @@ const GitHubIssueThreadSurface: React.FC = ({ externalStatusConfig: { currentStatusId: issue.state, options: [ - { id: "open", label: "Open" }, - { id: "closed", label: "Closed" }, + { + id: "open", + label: t("git.issues.status.open"), + color: "var(--color-success-6)", + }, + { + id: "closed", + label: t("git.issues.status.closed"), + color: "var(--color-purple-6)", + }, ], disabled: !interaction.canManageStatus || interaction.updatingStatus, onChangeStatusId: (statusId) => { diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueCloseButton.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueCloseButton.tsx index 5f0da5cf1..9228a4333 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueCloseButton.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueCloseButton.tsx @@ -5,6 +5,7 @@ import { CircleDot, CircleSlash, Copy, + Loader2, } from "lucide-react"; import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -17,7 +18,6 @@ import { DROPDOWN_ITEM, DROPDOWN_WIDTHS, } from "@src/components/Dropdown/tokens"; -import { LoadingBar } from "@src/modules/shared/layouts/blocks"; import type { GitHubIssueInteractionConfig, @@ -106,8 +106,16 @@ const GitHubIssueCloseButton: React.FC = ({ autoFocus /> {interaction.loadingDuplicateCandidates ? ( -
- +
+ + {t("actions.loading")}
) : interaction.duplicateCandidatesError ? (
@@ -162,6 +170,8 @@ const GitHubIssueCloseButton: React.FC = ({ } onClick={closeMenu} + disabled={interaction.issueState === "open"} + dataTestId="github-issue-status-open" > {t("git.issues.status.open")} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueComposer.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueComposer.tsx index 9302b6b4e..50ab7bb98 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueComposer.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/GitHubIssueComposer.tsx @@ -73,8 +73,25 @@ const GitHubIssueComposer: React.FC = ({
- + {interaction.canManageStatus ? ( +
+ +
+ ) : null} + + setCommentBody(markdown)} @@ -84,6 +101,8 @@ const GitHubIssueComposer: React.FC = ({ maxHeight={500} appearance="plain" toolbarMode="inline" + toolbarSize="mini" + toolbarDropdownPosition="top-start" editable={interaction.canComment && !interaction.submittingComment} dataTestId="github-issue-comment-editor" /> @@ -118,26 +137,18 @@ const GitHubIssueComposer: React.FC = ({ )} -
- {interaction.canManageStatus ? ( - - ) : null} - -
+
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx index 08f4aa5b9..aec22b219 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx @@ -126,6 +126,8 @@ const HistoryTab: React.FC = ({ maxHeight={120} appearance="plain" matchMarkdownPreview={false} + toolbarSize="mini" + toolbarDropdownPosition="top-start" dataTestId="work-item-comment-editor" /> {submitButton} @@ -150,6 +152,8 @@ const HistoryTab: React.FC = ({ minHeight={60} maxHeight={120} appearance="outlined" + toolbarSize="mini" + toolbarDropdownPosition="top-start" dataTestId="work-item-comment-editor" />
{submitButton}
diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/GitHubIssueComposer.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/GitHubIssueComposer.test.ts index 5278e2536..afd1804d6 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/GitHubIssueComposer.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/GitHubIssueComposer.test.ts @@ -131,6 +131,23 @@ describe("GitHubIssueComposer", () => { ); expect(editor?.dataset.toolbarMode).toBe("inline"); expect(editor?.dataset.minHeight).toBe("140"); + const levelActions = container.querySelector( + "[data-testid='github-issue-level-actions']" + ); + const input = container.querySelector( + "[data-testid='github-issue-comment-input']" + ); + expect(levelActions?.nextElementSibling).toBe(input); + expect(input?.contains(levelActions as Node)).toBe(false); + expect(levelActions?.className).not.toContain("border-"); + expect( + input?.querySelector("[data-testid='github-issue-comment-submit']") + ).not.toBeNull(); + expect( + levelActions?.querySelector( + "[data-testid='github-issue-comment-status-action']" + ) + ).not.toBeNull(); act(() => { const valueSetter = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, @@ -201,6 +218,11 @@ describe("GitHubIssueComposer", () => { expect( document.querySelector("[data-testid='github-issue-close-menu']") ).not.toBeNull(); + expect( + document + .querySelector("[data-testid='github-issue-status-open']") + ?.getAttribute("aria-disabled") + ).toBe("true"); expect(config.onLoadDuplicateCandidates).not.toHaveBeenCalled(); await act(async () => { @@ -217,6 +239,34 @@ describe("GitHubIssueComposer", () => { ).not.toBeNull(); }); + it("uses an inline spinner while duplicate issues load", async () => { + const config = interaction({ loadingDuplicateCandidates: true }); + act(() => { + root.render(createElement(GitHubIssueComposer, { interaction: config })); + }); + + await act(async () => { + container + .querySelectorAll(".button-split-wrapper button")[1] + ?.click(); + await Promise.resolve(); + }); + act(() => { + document + .querySelector( + "[data-testid='github-issue-close-duplicate']" + ) + ?.click(); + }); + + const loading = document.querySelector( + "[data-testid='github-issue-duplicate-loading']" + ); + expect(loading?.querySelector(".animate-spin")).not.toBeNull(); + expect(loading?.textContent).toContain("actions.loading"); + expect(config.onLoadDuplicateCandidates).not.toHaveBeenCalled(); + }); + it("closes as a duplicate with the selected canonical issue database ID", async () => { const canonicalIssue = { id: 100_987, diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index e2d4f7a69..e7ddd196e 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -553,6 +553,8 @@ const WorkItemContent: React.FC = ({ maxHeight={360} appearance="plain" toolbarMode="inline" + toolbarSize="mini" + toolbarDropdownPosition="top-start" editable={ canEditDescription && !githubIssueInteraction?.updatingBody } @@ -741,8 +743,19 @@ const WorkItemContent: React.FC = ({ ); if (isThread) { + const githubIssueComposer = + activeThreadView === "overview" && + isGitHubWorkItem && + githubIssueInteraction ? ( + + ) : undefined; + return ( - + {activeThreadView === "overview" ? ( <> {handoffNotice} @@ -751,13 +764,7 @@ const WorkItemContent: React.FC = ({ {todosSection} {threadLowerSection} - {isGitHubWorkItem && githubIssueInteraction ? ( - - - - ) : ( + {!isGitHubWorkItem || !githubIssueInteraction ? ( @@ -777,7 +784,7 @@ const WorkItemContent: React.FC = ({ /> - )} + ) : null} ) : ( historyContent diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/EnumPropertyField.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/EnumPropertyField.tsx index 7c01af06f..ab789e4c5 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/EnumPropertyField.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/EnumPropertyField.tsx @@ -10,6 +10,7 @@ interface EnumOption { value: T; icon?: React.ReactNode; color?: string; + disabled?: boolean; } interface EnumPropertyFieldProps { @@ -51,6 +52,7 @@ export function EnumPropertyField({ label: getLabel(option.value), icon: option.icon, iconColor: option.color, + disabled: option.disabled, }) ); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/StatusPrioritySection.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/StatusPrioritySection.tsx index e9323d2c7..a7e731822 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/StatusPrioritySection.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemProperties/StatusPrioritySection.tsx @@ -1,4 +1,4 @@ -import { Circle } from "lucide-react"; +import { CheckCircle2, Circle, CircleDot } from "lucide-react"; import { useState } from "react"; import type { FieldRowVariant } from "@src/components/PropertyField/PropertyFieldEditable"; @@ -63,9 +63,15 @@ export function StatusPrioritySection({ externalStatusConfig?.options.map((option) => ({ value: option.id, color: option.color, - icon: ( - - ), + disabled: option.id === externalStatusConfig.currentStatusId, + icon: + option.id === "open" ? ( + + ) : option.id === "closed" ? ( + + ) : ( + + ), })) ?? []; const currentExternalStatusOption = externalStatusConfig ? externalStatusOptions.find( diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/WorkItemThreadLayout.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/WorkItemThreadLayout.test.ts new file mode 100644 index 000000000..07b9b9d5c --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/WorkItemThreadLayout.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { type ComponentProps, type ReactNode, act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { WorkItemThreadLayout } from "."; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/modules/shared/layouts/blocks", () => ({ + DetailPanelContainer: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + ScrollTrail: () => null, +})); + +describe("WorkItemThreadLayout floating footer", () => { + let container: HTMLDivElement; + let root: Root; + const observe = vi.fn(); + const disconnect = vi.fn(); + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + observe.mockClear(); + disconnect.mockClear(); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe = observe; + disconnect = disconnect; + } + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("reserves the footer inset and disposes its observer on unmount", () => { + const removeWindowListener = vi.spyOn(window, "removeEventListener"); + const props: ComponentProps = { + floatingFooter: createElement("div", null, "Composer"), + children: createElement("div", null, "Timeline"), + }; + + act(() => { + root.render(createElement(WorkItemThreadLayout, props)); + }); + + const footer = container.querySelector( + '[data-testid="work-item-thread-floating-footer"]' + ); + const content = container.querySelector( + '[data-testid="work-item-thread-section"] > div' + ); + expect(footer?.className).toContain("absolute"); + expect(footer?.className).toContain("bottom-0"); + expect(content?.getAttribute("style")).toContain("padding-bottom: 240px"); + expect(observe).toHaveBeenCalledWith(footer); + + act(() => root.unmount()); + root = createRoot(container); + + expect(disconnect).toHaveBeenCalledOnce(); + expect(removeWindowListener).toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + }); + + it("does not retain measurement resources without a floating footer", () => { + const addWindowListener = vi.spyOn(window, "addEventListener"); + + act(() => { + root.render( + createElement( + WorkItemThreadLayout, + null, + createElement("div", null, "Timeline") + ) + ); + }); + + expect(observe).not.toHaveBeenCalled(); + expect(addWindowListener).not.toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + expect( + container.querySelector( + '[data-testid="work-item-thread-floating-footer"]' + ) + ).toBeNull(); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx index 7c1fa2bcc..eea19267d 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/index.tsx @@ -1,6 +1,7 @@ import React, { useId, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { useElementDimensions } from "@src/hooks/ui/layout/useElementDimensions"; import { DetailPanelContainer, ScrollTrail, @@ -13,16 +14,26 @@ interface WorkItemThreadLayoutProps { path?: React.ReactNode; properties?: React.ReactNode; children: React.ReactNode; + floatingFooter?: React.ReactNode; } export const WorkItemThreadLayout: React.FC = ({ path, properties, children, + floatingFooter, }) => { const { t } = useTranslation(["projects", "common"]); const scrollContainerRef = useRef(null); const contentRef = useRef(null); + const floatingFooterRef = useRef(null); + const measuredFooterHeight = useElementDimensions(floatingFooterRef, { + dimension: "height", + enabled: Boolean(floatingFooter), + }); + const footerBottomInset = floatingFooter + ? Math.max(240, measuredFooterHeight) + : undefined; const headerPolicy = resolveWorkItemThreadHeaderPolicy( Boolean(path), Boolean(properties) @@ -30,7 +41,7 @@ export const WorkItemThreadLayout: React.FC = ({ return ( -
+
= ({
{headerPolicy.showHeader ? (
@@ -57,6 +69,21 @@ export const WorkItemThreadLayout: React.FC = ({ {children}
+ {floatingFooter ? ( +
+
+
+ {floatingFooter} +
+
+ ) : null}
= memo( minHeight={96} maxHeight={240} appearance="outlined" + toolbarSize="mini" + toolbarDropdownPosition="top-start" dataTestId="new-issue-body-editor" />