diff --git a/src/components/Insights/tickets/EndpointNPlusOneInsightTicket/index.tsx b/src/components/Insights/tickets/EndpointNPlusOneInsightTicket/index.tsx
index 140980abb..08b8546b1 100644
--- a/src/components/Insights/tickets/EndpointNPlusOneInsightTicket/index.tsx
+++ b/src/components/Insights/tickets/EndpointNPlusOneInsightTicket/index.tsx
@@ -84,21 +84,22 @@ export const EndpointNPlusOneInsightTicket = (
);
const onReloadSpanInsight = () => {
- spanInfo?.spanCodeObjectId && window.sendMessageToDigma({
- action: actions.GET_SPAN_INSIGHT,
- payload: {
- spanCodeObjectId: spanInfo?.spanCodeObjectId,
- insightType: InsightType.SpanNPlusOne
- }
- });
- }
+ spanInfo?.spanCodeObjectId &&
+ window.sendMessageToDigma({
+ action: actions.GET_SPAN_INSIGHT,
+ payload: {
+ spanCodeObjectId: spanInfo?.spanCodeObjectId,
+ insightType: InsightType.SpanNPlusOne
+ }
+ });
+ };
const traceId = span?.traceId;
const attachment = traceId
? {
- url: `${config.jaegerURL}/api/traces/${traceId}?prettyPrint=true`,
- fileName: `trace-${traceId}.json`
- }
+ url: `${config.jaegerURL}/api/traces/${traceId}?prettyPrint=true`,
+ fileName: `trace-${traceId}.json`
+ }
: undefined;
useEffect(() => {
diff --git a/src/components/Insights/tickets/SpanBottleneckInsightTicket/index.tsx b/src/components/Insights/tickets/SpanBottleneckInsightTicket/index.tsx
index 6c531893a..01452a646 100644
--- a/src/components/Insights/tickets/SpanBottleneckInsightTicket/index.tsx
+++ b/src/components/Insights/tickets/SpanBottleneckInsightTicket/index.tsx
@@ -75,14 +75,15 @@ export const SpanBottleneckInsightTicket = (
);
const onReloadSpanInsight = () => {
- spanInsight?.spanInfo?.spanCodeObjectId && window.sendMessageToDigma({
- action: actions.GET_SPAN_INSIGHT,
- payload: {
- spanCodeObjectId: spanInsight?.spanInfo?.spanCodeObjectId,
- insightType: InsightType.SpanEndpointBottleneck
- }
- });
- }
+ spanInsight?.spanInfo?.spanCodeObjectId &&
+ window.sendMessageToDigma({
+ action: actions.GET_SPAN_INSIGHT,
+ payload: {
+ spanCodeObjectId: spanInsight?.spanInfo?.spanCodeObjectId,
+ insightType: InsightType.SpanEndpointBottleneck
+ }
+ });
+ };
useEffect(() => {
const spanCodeObjectId = span?.spanInfo.spanCodeObjectId;
diff --git a/src/components/Insights/tickets/common/BottleneckEndpoints/index.tsx b/src/components/Insights/tickets/common/BottleneckEndpoints/index.tsx
index b2e525e91..6b53fbb2e 100644
--- a/src/components/Insights/tickets/common/BottleneckEndpoints/index.tsx
+++ b/src/components/Insights/tickets/common/BottleneckEndpoints/index.tsx
@@ -1,3 +1,4 @@
+import { getDurationString } from "../../../../../utils/getDurationString";
import { roundTo } from "../../../../../utils/roundTo";
import { trimEndpointScheme } from "../../../../../utils/trimEndpointScheme";
import * as s from "./styles";
@@ -29,8 +30,7 @@ export const BottleneckEndpoints = (props: BottleneckEndpointsProps) => {
Slowing {roundTo(x.probabilityOfBeingBottleneck * 100, 2)}% of the
- requests (~{x.avgDurationWhenBeingBottleneck.value}{" "}
- {x.avgDurationWhenBeingBottleneck.unit})
+ requests (~{getDurationString(x.avgDurationWhenBeingBottleneck)})
))}
diff --git a/src/components/RecentActivity/LiveView/AreaTooltipContent/index.tsx b/src/components/RecentActivity/LiveView/AreaTooltipContent/index.tsx
index 4115c2520..000e8ef7f 100644
--- a/src/components/RecentActivity/LiveView/AreaTooltipContent/index.tsx
+++ b/src/components/RecentActivity/LiveView/AreaTooltipContent/index.tsx
@@ -1,3 +1,4 @@
+import { getDurationString } from "../../../../utils/getDurationString";
import { TooltipContent } from "../TooltipContent";
import * as s from "./styles";
import { AreaTooltipContentProps } from "./types";
@@ -10,9 +11,7 @@ export const AreaTooltipContent = (
{[props.p95, props.p50].map((x) => (
{x.label}
-
- {x.duration.value} {x.duration.unit}
-
+ {getDurationString(x.duration)}
))}
diff --git a/src/components/RecentActivity/LiveView/DotTooltipContent/index.tsx b/src/components/RecentActivity/LiveView/DotTooltipContent/index.tsx
index b60641eac..6fbaf0cd4 100644
--- a/src/components/RecentActivity/LiveView/DotTooltipContent/index.tsx
+++ b/src/components/RecentActivity/LiveView/DotTooltipContent/index.tsx
@@ -1,4 +1,5 @@
import { format } from "date-fns";
+import { getDurationString } from "../../../../utils/getDurationString";
import { TooltipContent } from "../TooltipContent";
import * as s from "./styles";
import { DotTooltipContentProps } from "./types";
@@ -16,7 +17,7 @@ export const DotTooltipContent = (
{format(date, "MM/dd/yyyy")}
- {props.data.duration.value} {props.data.duration.unit}
+ {getDurationString(props.data.duration)}
);
diff --git a/src/components/RecentActivity/RecentActivityTable/index.tsx b/src/components/RecentActivity/RecentActivityTable/index.tsx
index 5d4e6afc1..18adaf07f 100644
--- a/src/components/RecentActivity/RecentActivityTable/index.tsx
+++ b/src/components/RecentActivity/RecentActivityTable/index.tsx
@@ -8,6 +8,7 @@ import { useMemo } from "react";
import { Duration } from "../../../globals";
import { isNumber } from "../../../typeGuards/isNumber";
import { formatTimeDistance } from "../../../utils/formatTimeDistance";
+import { getDurationString } from "../../../utils/getDurationString";
import { getInsightTypeInfo } from "../../../utils/getInsightTypeInfo";
import { getInsightTypeOrderPriority } from "../../../utils/getInsightTypeOrderPriority";
import { greenScale } from "../../common/App/getTheme";
@@ -62,7 +63,7 @@ const renderTimeDistance = (timestamp: string, viewMode: ViewMode) => {
const renderDuration = (duration: Duration, viewMode: ViewMode) =>
viewMode === "table" ? (
-
+
) : (
{duration.value}
diff --git a/src/components/Tests/EnvironmentFilter/index.tsx b/src/components/Tests/EnvironmentFilter/index.tsx
new file mode 100644
index 000000000..5d7e2d6f8
--- /dev/null
+++ b/src/components/Tests/EnvironmentFilter/index.tsx
@@ -0,0 +1,62 @@
+import { useState } from "react";
+import { FilterMenu } from "../../Assets/FilterMenu";
+import { NewPopover } from "../../common/NewPopover";
+import { ChevronIcon } from "../../common/icons/ChevronIcon";
+import { GlobeIcon } from "../../common/icons/GlobeIcon";
+import { Direction } from "../../common/icons/types";
+import * as s from "./styles";
+import { EnvironmentFilterProps } from "./types";
+
+export const EnvironmentFilter = (props: EnvironmentFilterProps) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const handleMenuItemClick = (value: string) => {
+ props.onMenuItemClick(value);
+ };
+
+ const handleServiceMenuClose = () => {
+ setIsMenuOpen(false);
+ };
+
+ const selectedItems = props.items.filter((x) => x.selected);
+
+ return (
+
+ }
+ onOpenChange={setIsMenuOpen}
+ isOpen={isMenuOpen}
+ placement={"bottom-start"}
+ >
+
+
+
+
+
+ Environment :
+ {selectedItems && selectedItems.length > 0 && !props.isLoading ? (
+ {selectedItems.length}
+ ) : (
+
+ All
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Tests/EnvironmentFilter/styles.ts b/src/components/Tests/EnvironmentFilter/styles.ts
new file mode 100644
index 000000000..592b7d942
--- /dev/null
+++ b/src/components/Tests/EnvironmentFilter/styles.ts
@@ -0,0 +1,72 @@
+import styled from "styled-components";
+import { MenuButtonProps } from "./types";
+
+export const MenuButton = styled.button`
+ border: 1px solid
+ ${({ theme, $isOpen }) =>
+ $isOpen ? theme.colors.stroke.brand : theme.colors.stroke.primary};
+ background: ${({ theme }) => theme.colors.surface.secondary};
+ border-radius: 4px;
+ padding: 4px 6px 4px 4px;
+ display: flex;
+ gap: 10px;
+ align-items: center;
+
+ &:hover {
+ border: 1px solid ${({ theme }) => theme.colors.stroke.secondary};
+ }
+
+ &:focus,
+ &:active {
+ border: 1px solid ${({ theme }) => theme.colors.stroke.brand};
+ }
+`;
+
+export const MenuButtonLabel = styled.span`
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ font-size: 14px;
+ color: ${({ theme }) => theme.colors.text.base};
+`;
+
+export const IconContainer = styled.span`
+ display: flex;
+ padding: 2px;
+ border-radius: 4px;
+ background: ${({ theme }) => theme.colors.surface.brand};
+ color: ${({ theme }) => theme.colors.icon.white};
+`;
+
+export const SelectedEntriesNumberPlaceholder = styled.span`
+ color: ${({ theme }) => {
+ switch (theme.mode) {
+ case "light":
+ return "#494b57";
+ case "dark":
+ case "dark-jetbrains":
+ return "#dfe1e5";
+ }
+ }};
+ user-select: none;
+`;
+
+export const Number = styled.span`
+ min-width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ font-size: 14px;
+ line-height: 100%;
+ font-weight: 500;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ background: #5053d4;
+`;
+
+export const MenuChevronIconContainer = styled.span`
+ margin-left: auto;
+ color: ${({ theme }) => theme.colors.icon.primary};
+`;
diff --git a/src/components/Tests/EnvironmentFilter/types.ts b/src/components/Tests/EnvironmentFilter/types.ts
new file mode 100644
index 000000000..5bd73c00a
--- /dev/null
+++ b/src/components/Tests/EnvironmentFilter/types.ts
@@ -0,0 +1,11 @@
+import { MenuItem } from "../../Assets/FilterMenu/types";
+
+export interface EnvironmentFilterProps {
+ items: MenuItem[];
+ onMenuItemClick: (value: string) => void;
+ isLoading: boolean;
+}
+
+export interface MenuButtonProps {
+ $isOpen: boolean;
+}
diff --git a/src/components/Tests/TestCard/TestCard.stories.tsx b/src/components/Tests/TestCard/TestCard.stories.tsx
new file mode 100644
index 000000000..f22ab8ebe
--- /dev/null
+++ b/src/components/Tests/TestCard/TestCard.stories.tsx
@@ -0,0 +1,49 @@
+import { Meta, StoryObj } from "@storybook/react";
+
+import { TestCard } from ".";
+import { mockedTest } from "./mockData";
+
+// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
+const meta: Meta = {
+ title: "Tests/TestCard",
+ component: TestCard,
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
+ layout: "fullscreen"
+ }
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
+export const Passed: Story = {
+ args: {
+ spanContexts: [
+ {
+ displayName: "spanDisplayName",
+ spanCodeObjectId: "123",
+ methodCodeObjectId: "methodCodeObjectId123"
+ }
+ ],
+ test: mockedTest
+ }
+};
+
+export const Failed: Story = {
+ args: {
+ spanContexts: [
+ {
+ displayName: "spanDisplayName",
+ spanCodeObjectId: "123",
+ methodCodeObjectId: "methodCodeObjectId123"
+ }
+ ],
+ test: {
+ ...mockedTest,
+ result: "fail",
+ errorOrFailMessage: "Assertion error message"
+ }
+ }
+};
diff --git a/src/components/Tests/TestCard/index.tsx b/src/components/Tests/TestCard/index.tsx
new file mode 100644
index 000000000..012348e81
--- /dev/null
+++ b/src/components/Tests/TestCard/index.tsx
@@ -0,0 +1,168 @@
+import { isString } from "../../../typeGuards/isString";
+import { formatTimeDistance } from "../../../utils/formatTimeDistance";
+import { getDurationString } from "../../../utils/getDurationString";
+import { sendTrackingEvent } from "../../../utils/sendTrackingEvent";
+import { NewButton } from "../../common/NewButton";
+import { Tag } from "../../common/Tag";
+import { Tooltip } from "../../common/Tooltip";
+import { TraceIcon } from "../../common/icons/12px/TraceIcon";
+import { JiraLogoIcon } from "../../common/icons/16px/JiraLogoIcon";
+import { TimerIcon } from "../../common/icons/16px/TimerIcon";
+import { CheckmarkCircleInvertedIcon } from "../../common/icons/CheckmarkCircleInvertedIcon";
+import { CrossCircleIcon } from "../../common/icons/CrossCircleIcon";
+import { GlobeIcon } from "../../common/icons/GlobeIcon";
+import { PlayIcon } from "../../common/icons/PlayIcon";
+import { actions } from "../actions";
+import { trackingEvents } from "../tracking";
+import { Test } from "../types";
+import * as s from "./styles";
+import { TestCardProps } from "./types";
+
+const renderTestResultTag = (test: Test) => {
+ switch (test.result) {
+ case "success":
+ return (
+
+ );
+ case "fail":
+ return (
+
+ );
+ case "error":
+ return (
+
+ );
+ }
+};
+
+export const TestCard = (props: TestCardProps) => {
+ const handleTestNameClick = () => {
+ sendTrackingEvent(trackingEvents.TEST_NAME_LINK_CLICKED);
+ window.sendMessageToDigma({
+ action: actions.GO_TO_SPAN_OF_TEST,
+ payload: {
+ environment: props.test.environmentId,
+ spanCodeObjectId: props.test.spanInfo.spanCodeObjectId,
+ methodCodeObjectId: props.test.spanInfo.methodCodeObjectId
+ }
+ });
+ };
+
+ const handleTicketButtonClick = () => {
+ sendTrackingEvent(trackingEvents.JIRA_TICKET_INFO_BUTTON_CLICKED);
+ props.onTicketInfoOpen(props.test);
+ };
+
+ const handleTraceButtonClick = () => {
+ sendTrackingEvent(trackingEvents.GO_TO_TRACE_BUTTON_CLICKED);
+ const spanContext = props.spanContexts.find((context) => {
+ const id = props.test.contextsSpanCodeObjectIds.find(
+ (x) => x === context.spanCodeObjectId
+ );
+
+ return context.spanCodeObjectId === id;
+ });
+
+ window.sendMessageToDigma({
+ action: actions.GO_TO_TRACE,
+ payload: {
+ traceId: props.test.traceId,
+ displayName: spanContext?.displayName,
+ spanCodeObjectId: spanContext?.spanCodeObjectId
+ }
+ });
+ };
+
+ const handleRunButtonClick = () => {
+ sendTrackingEvent(trackingEvents.RUN_TEST_BUTTON_CLICKED);
+ window.sendMessageToDigma({
+ action: actions.RUN_TEST,
+ payload: {
+ methodCodeObjectId: props.test.spanInfo.methodCodeObjectId
+ }
+ });
+ };
+
+ const durationString = getDurationString(props.test.duration);
+
+ return (
+
+
+ {renderTestResultTag(props.test)}
+
+
+ {props.test.name}
+
+
+
+
+
+
+
+
+
+ {props.test.environment}
+
+
+
+
+
+
+
+ {formatTimeDistance(props.test.runAt)}
+
+
+
+
+ Duration
+ {durationString}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Tests/TestCard/mockData.ts b/src/components/Tests/TestCard/mockData.ts
new file mode 100644
index 000000000..a6adfebad
--- /dev/null
+++ b/src/components/Tests/TestCard/mockData.ts
@@ -0,0 +1,31 @@
+import { Test } from "../types";
+
+export const mockedTest: Test = {
+ name: "GET /owners/{ownerId}/pets/{petId}/visits/new",
+ spanInfo: {
+ name: "GET /owners/{ownerId}/pets/{petId}/visits/new",
+ displayName: "GET /owners/{ownerId}/pets/{petId}/visits/new",
+ instrumentationLibrary: "com.digma.junit",
+ spanCodeObjectId:
+ "span:com.digma.junit$_$GET /owners/{ownerId}/pets/{petId}/visits/new",
+ methodCodeObjectId:
+ "org.springframework.samples.petclinic.owner.VisitController$_$initNewVisitForm",
+ kind: "Internal",
+ codeObjectId:
+ "org.springframework.samples.petclinic.owner.VisitController$_$initNewVisitForm"
+ },
+ result: "success",
+ runAt: "2024-01-04T16:06:46.568728Z",
+ duration: {
+ value: 1.11,
+ unit: "μs",
+ raw: 1111
+ },
+ environment: "BOB-MACBOOK-PRO-2.LOCAL[LOCAL-TESTS]",
+ environmentId: "BOB-MACBOOK-PRO-2.LOCAL[LOCAL-TESTS]#ID#1",
+ traceId: "E03E928B296A8C69511F09422DE6CDA5",
+ ticketId: null,
+ commitId: null,
+ errorOrFailMessage: null,
+ contextsSpanCodeObjectIds: ["123"]
+};
diff --git a/src/components/Tests/TestCard/styles.ts b/src/components/Tests/TestCard/styles.ts
new file mode 100644
index 000000000..a36334d25
--- /dev/null
+++ b/src/components/Tests/TestCard/styles.ts
@@ -0,0 +1,63 @@
+import styled from "styled-components";
+import { Link } from "../../common/Link";
+
+export const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ background: ${({ theme }) => theme.colors.surface.card};
+ border-radius: 4px;
+ border: 1px solid ${({ theme }) => theme.colors.stroke.primary};
+ font-size: 14px;
+`;
+
+export const TestNameLink = styled(Link)`
+ color: ${({ theme }) => theme.colors.text.link};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+export const Header = styled.div`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ color: ${({ theme }) => theme.colors.text.base};
+ font-weight: 500;
+ border-bottom: 1px solid ${({ theme }) => theme.colors.stroke.primary};
+ padding: 8px;
+`;
+
+export const Content = styled.div`
+ display: flex;
+ gap: 4px;
+ padding: 8px;
+ flex-wrap: wrap;
+`;
+
+export const Stat = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ max-width: 150px;
+ color: ${({ theme }) => theme.colors.text.subtext};
+`;
+
+export const IconContainer = styled.div`
+ display: flex;
+ color: ${({ theme }) => theme.colors.icon.disabledAlt};
+`;
+
+export const StatValue = styled.span`
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+export const ButtonsContainer = styled.div`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-left: auto;
+ margin-top: auto;
+`;
diff --git a/src/components/Tests/TestCard/types.ts b/src/components/Tests/TestCard/types.ts
new file mode 100644
index 000000000..70c4f135e
--- /dev/null
+++ b/src/components/Tests/TestCard/types.ts
@@ -0,0 +1,7 @@
+import { Test, TestsData } from "../types";
+
+export interface TestCardProps {
+ test: Test;
+ spanContexts: TestsData["spanContexts"];
+ onTicketInfoOpen: (test: Test) => void;
+}
diff --git a/src/components/Tests/TestTicket/index.tsx b/src/components/Tests/TestTicket/index.tsx
new file mode 100644
index 000000000..2f78ce92b
--- /dev/null
+++ b/src/components/Tests/TestTicket/index.tsx
@@ -0,0 +1,39 @@
+import { isString } from "../../../typeGuards/isString";
+import { getDurationString } from "../../../utils/getDurationString";
+import { JiraTicket } from "../../common/JiraTicket";
+import { TestTicketProps } from "./types";
+
+export const TestTicket = (props: TestTicketProps) => {
+ const summary = `"${props.test.name}" test failed`;
+
+ const relatedSpans = props.spanContexts
+ .filter((x) =>
+ props.test.contextsSpanCodeObjectIds.includes(x.spanCodeObjectId)
+ )
+ .map((x) => x.displayName)
+ .join("\n");
+
+ const description = [
+ `"${props.test.name}" test failed${
+ isString(props.test.errorOrFailMessage)
+ ? ` with message:\n${props.test.errorOrFailMessage}`
+ : ""
+ }`,
+ `Last run at: ${new Date(props.test.runAt).toString()}`,
+ `Duration: ${getDurationString(props.test.duration)}`,
+ relatedSpans.length > 0 ? `Related spans:\n${relatedSpans}` : ""
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+
+ return (
+
+ );
+};
diff --git a/src/components/Tests/TestTicket/types.ts b/src/components/Tests/TestTicket/types.ts
new file mode 100644
index 000000000..7cbbae81d
--- /dev/null
+++ b/src/components/Tests/TestTicket/types.ts
@@ -0,0 +1,7 @@
+import { Test, TestsData } from "../types";
+
+export interface TestTicketProps {
+ test: Test;
+ spanContexts: TestsData["spanContexts"];
+ onClose: () => void;
+}
diff --git a/src/components/Tests/Tests.stories.tsx b/src/components/Tests/Tests.stories.tsx
new file mode 100644
index 000000000..9764db579
--- /dev/null
+++ b/src/components/Tests/Tests.stories.tsx
@@ -0,0 +1,93 @@
+import { Meta, StoryObj } from "@storybook/react";
+
+import { Tests } from ".";
+import { mockedTest } from "./TestCard/mockData";
+
+// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
+const meta: Meta = {
+ title: "Tests/Tests",
+ component: Tests,
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
+ layout: "fullscreen"
+ }
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
+export const Default: Story = {
+ args: {
+ data: {
+ data: {
+ paging: {
+ pageNumber: 1,
+ pageSize: 10,
+ totalCount: 12
+ },
+ spanContexts: [
+ {
+ displayName: "spanDisplayName",
+ spanCodeObjectId: "123",
+ methodCodeObjectId: "methodCodeObjectId123"
+ }
+ ],
+ entries: [
+ { ...mockedTest, name: "Test 1", result: "fail" },
+ { ...mockedTest, name: "Test 2" },
+ { ...mockedTest, name: "Test 3" },
+ { ...mockedTest, name: "Test 4" },
+ { ...mockedTest, name: "Test 5" },
+ { ...mockedTest, name: "Test 6" },
+ { ...mockedTest, name: "Test 7" },
+ { ...mockedTest, name: "Test 8" },
+ { ...mockedTest, name: "Test 9" },
+ { ...mockedTest, name: "Test 10" },
+ { ...mockedTest, name: "Test 11" },
+ { ...mockedTest, name: "Test 12" }
+ ]
+ },
+ error: null
+ }
+ }
+};
+
+export const Empty: Story = {
+ args: {
+ data: {
+ data: {
+ paging: {
+ pageNumber: 1,
+ pageSize: 10,
+ totalCount: 0
+ },
+ spanContexts: [
+ {
+ displayName: "spanDisplayName",
+ spanCodeObjectId: "123",
+ methodCodeObjectId: "methodCodeObjectId123"
+ }
+ ],
+ entries: []
+ },
+ error: null
+ }
+ }
+};
+
+export const Error: Story = {
+ args: {
+ data: {
+ data: null,
+ error: {
+ message: "Error message"
+ }
+ }
+ }
+};
+
+export const Loading: Story = {
+ args: {}
+};
diff --git a/src/components/Tests/actions.ts b/src/components/Tests/actions.ts
new file mode 100644
index 000000000..9c24358ff
--- /dev/null
+++ b/src/components/Tests/actions.ts
@@ -0,0 +1,12 @@
+import { addPrefix } from "../../utils/addPrefix";
+
+const ACTION_PREFIX = "TESTS";
+
+export const actions = addPrefix(ACTION_PREFIX, {
+ INITIALIZE: "INITIALIZE",
+ GET_SPAN_LATEST_DATA: "SPAN_GET_LATEST_DATA",
+ SET_SPAN_LATEST_DATA: "SPAN_SET_LATEST_DATA",
+ RUN_TEST: "RUN_TEST",
+ GO_TO_TRACE: "GO_TO_TRACE",
+ GO_TO_SPAN_OF_TEST: "GO_TO_SPAN_OF_TEST"
+});
diff --git a/src/components/Tests/index.tsx b/src/components/Tests/index.tsx
new file mode 100644
index 000000000..36d865a70
--- /dev/null
+++ b/src/components/Tests/index.tsx
@@ -0,0 +1,292 @@
+import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { actions as globalActions } from "../../actions";
+import { dispatcher } from "../../dispatcher";
+import { usePrevious } from "../../hooks/usePrevious";
+import { isNumber } from "../../typeGuards/isNumber";
+import { sendTrackingEvent } from "../../utils/sendTrackingEvent";
+import { MenuItem } from "../Assets/FilterMenu/types";
+import { ConfigContext } from "../common/App/ConfigContext";
+import { NewCircleLoader } from "../common/NewCircleLoader";
+import { Pagination } from "../common/Pagination";
+import { RegistrationDialog } from "../common/RegistrationDialog";
+import { RegistrationFormValues } from "../common/RegistrationDialog/types";
+import { EnvironmentFilter } from "./EnvironmentFilter";
+import { TestCard } from "./TestCard";
+import { TestTicket } from "./TestTicket";
+import { actions } from "./actions";
+import * as s from "./styles";
+import { trackingEvents } from "./tracking";
+import { SetSpanLatestDataPayload, Test, TestsProps } from "./types";
+
+const PAGE_SIZE = 10;
+const REFRESH_INTERVAL = isNumber(window.testsRefreshInterval)
+ ? window.testsRefreshInterval
+ : 10 * 1000; // in milliseconds
+
+export const Tests = (props: TestsProps) => {
+ const [data, setData] = useState();
+ const previousData = usePrevious(data);
+ const [page, setPage] = useState(0);
+ const refreshTimerId = useRef();
+ const [isInitialLoading, setIsInitialLoading] = useState(false);
+ const [lastSetDataTimeStamp, setLastSetDataTimeStamp] = useState();
+ const previousLastSetDataTimeStamp = usePrevious(lastSetDataTimeStamp);
+ const config = useContext(ConfigContext);
+ const [testToOpenTicketPopup, setTestToOpenTicketPopup] = useState();
+ const previousUserRegistrationEmail = usePrevious(
+ config.userRegistrationEmail
+ );
+ useState(false);
+ const [isRegistrationInProgress, setIsRegistrationInProgress] =
+ useState(false);
+ const [selectedEnvironments, setSelectedEnvironments] = useState(
+ []
+ );
+ const testsListRef = useRef(null);
+
+ const totalCount = data?.data?.paging.totalCount || 0;
+ const pageStartItemNumber = page * PAGE_SIZE + 1;
+ const pageEndItemNumber = Math.min(
+ pageStartItemNumber + PAGE_SIZE - 1,
+ totalCount
+ );
+
+ const environmentMenuItems: MenuItem[] = (config.environments || []).map(
+ (environment) => ({
+ value: environment.originalName,
+ label: environment.name,
+ selected: selectedEnvironments.includes(environment.originalName)
+ })
+ );
+
+ const payloadToSend = useMemo(
+ () => ({
+ environments:
+ selectedEnvironments.length > 0
+ ? selectedEnvironments
+ : (config.environments || []).map((x) => x.originalName),
+ pageNumber: page + 1
+ }),
+ [page, selectedEnvironments, config.environments]
+ );
+ const previousPayloadToSend = usePrevious(payloadToSend);
+
+ useEffect(() => {
+ window.sendMessageToDigma({
+ action: actions.INITIALIZE,
+ payload: {
+ pageSize: PAGE_SIZE
+ }
+ });
+
+ sendTrackingEvent(trackingEvents.PAGE_LOADED);
+
+ window.sendMessageToDigma({
+ action: actions.GET_SPAN_LATEST_DATA,
+ payload: payloadToSend
+ });
+ setIsInitialLoading(true);
+
+ const handleSetSpanLatestData = (data: unknown, timeStamp: number) => {
+ setData(data as SetSpanLatestDataPayload);
+ setLastSetDataTimeStamp(timeStamp);
+ };
+
+ dispatcher.addActionListener(
+ actions.SET_SPAN_LATEST_DATA,
+ handleSetSpanLatestData
+ );
+
+ return () => {
+ dispatcher.removeActionListener(
+ actions.SET_SPAN_LATEST_DATA,
+ handleSetSpanLatestData
+ );
+ window.clearTimeout(refreshTimerId.current);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (previousLastSetDataTimeStamp !== lastSetDataTimeStamp) {
+ window.clearTimeout(refreshTimerId.current);
+ refreshTimerId.current = window.setTimeout(() => {
+ window.sendMessageToDigma({
+ action: actions.GET_SPAN_LATEST_DATA,
+ payload: payloadToSend
+ });
+ }, REFRESH_INTERVAL);
+ }
+ }, [previousLastSetDataTimeStamp, lastSetDataTimeStamp, payloadToSend]);
+
+ useEffect(() => {
+ if (previousPayloadToSend && previousPayloadToSend !== payloadToSend) {
+ window.sendMessageToDigma({
+ action: actions.GET_SPAN_LATEST_DATA,
+ payload: payloadToSend
+ });
+ }
+ }, [previousPayloadToSend, payloadToSend]);
+
+ useEffect(() => {
+ if (
+ previousUserRegistrationEmail !== config.userRegistrationEmail &&
+ isRegistrationInProgress
+ ) {
+ setIsRegistrationInProgress(false);
+ }
+ }, [
+ config.userRegistrationEmail,
+ isRegistrationInProgress,
+ previousUserRegistrationEmail
+ ]);
+
+ useEffect(() => {
+ if (!props.data) {
+ return;
+ }
+
+ setData(props.data);
+ }, [props.data]);
+
+ useEffect(() => {
+ if (!previousData && data) {
+ setIsInitialLoading(false);
+ }
+ }, [previousData, data]);
+
+ useEffect(() => {
+ setPage(0);
+ testsListRef.current?.scrollTo(0, 0);
+ }, [config.scope, selectedEnvironments]);
+
+ const openJiraTicketPopup = (test: Test) => {
+ setTestToOpenTicketPopup(test);
+ };
+
+ const closeJiraTicketPopup = () => {
+ setTestToOpenTicketPopup(undefined);
+ };
+
+ const handleRegistrationSubmit = (formData: RegistrationFormValues) => {
+ window.sendMessageToDigma({
+ action: globalActions.REGISTER,
+ payload: {
+ ...formData,
+ scope: "insights view jira ticket info"
+ }
+ });
+
+ setIsRegistrationInProgress(true);
+ };
+
+ const handleRegistrationDialogClose = () => {
+ setTestToOpenTicketPopup(undefined);
+ };
+
+ const handleEnvironmentMenuItemClick = (environment: string) => {
+ const oldSelectedEnvironments = selectedEnvironments || [];
+ const environmentIndex = oldSelectedEnvironments.findIndex(
+ (x) => x === environment
+ );
+
+ if (environmentIndex < 0) {
+ setSelectedEnvironments([...oldSelectedEnvironments, environment]);
+ } else {
+ setSelectedEnvironments([
+ ...oldSelectedEnvironments.slice(0, environmentIndex),
+ ...oldSelectedEnvironments.slice(environmentIndex + 1)
+ ]);
+ }
+ };
+
+ const renderContent = () => {
+ if (isInitialLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (data?.error) {
+ return {data.error.message};
+ }
+
+ if (data?.data?.entries.length === 0) {
+ return (
+
+ Run tests with Digma
+
+ Run your test with Digma enabled to see related tests and insights
+
+
+ );
+ }
+
+ return (
+
+
+ {data?.data?.entries.map((x) => {
+ const key = `${x.environmentId}-${x.name}`;
+ return (
+
+ );
+ })}
+
+
+
+ Showing{" "}
+
+ {pageStartItemNumber} - {pageEndItemNumber}
+ {" "}
+ of {totalCount}
+
+
+
+
+ );
+ };
+
+ return (
+
+
+ Environment
+
+
+ {renderContent()}
+ {testToOpenTicketPopup && (
+
+
+ {config.userRegistrationEmail ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/src/components/Tests/styles.ts b/src/components/Tests/styles.ts
new file mode 100644
index 000000000..01b5bd974
--- /dev/null
+++ b/src/components/Tests/styles.ts
@@ -0,0 +1,94 @@
+import styled from "styled-components";
+import { LAYERS } from "../common/App/styles";
+
+export const Container = styled.div`
+ background: ${({ theme }) => theme.colors.panel.background};
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+`;
+
+export const NoDataContainer = styled.div`
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ text-align: center;
+ color: ${({ theme }) => theme.colors.text.subtext};
+ font-size: 14px;
+`;
+
+export const EnvironmentFilterContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 8px 12px;
+ gap: 8px;
+ color: ${({ theme }) => theme.colors.text.subtext};
+ font-size: 14px;
+`;
+
+export const ContentContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 8px 12px;
+ gap: 12px;
+ overflow: auto;
+`;
+
+export const TestsList = styled.div`
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ gap: 12px;
+`;
+
+export const Footer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+`;
+
+export const ItemsCount = styled.span`
+ font-weight: 500;
+ color: ${({ theme }) => {
+ switch (theme.mode) {
+ case "light":
+ return "#818594";
+ case "dark":
+ case "dark-jetbrains":
+ return "#b4b8bf";
+ }
+ }};
+`;
+
+export const PageItemsCount = styled.span`
+ color: ${({ theme }) => {
+ switch (theme.mode) {
+ case "light":
+ return "#494b57";
+ case "dark":
+ case "dark-jetbrains":
+ return "#dfe1e5";
+ }
+ }};
+`;
+
+export const Overlay = styled.div`
+ position: fixed;
+ inset: 0;
+ margin: auto;
+ background: rgb(18 18 21 / 70%);
+ z-index: ${LAYERS.OVERLAY};
+`;
+
+export const PopupContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 0 4%;
+`;
diff --git a/src/components/Tests/tracking.ts b/src/components/Tests/tracking.ts
new file mode 100644
index 000000000..139e4776b
--- /dev/null
+++ b/src/components/Tests/tracking.ts
@@ -0,0 +1,15 @@
+import { addPrefix } from "../../utils/addPrefix";
+
+const TRACKING_PREFIX = "tests";
+
+export const trackingEvents = addPrefix(
+ TRACKING_PREFIX,
+ {
+ PAGE_LOADED: "page loaded",
+ TEST_NAME_LINK_CLICKED: "test name link clicked",
+ GO_TO_TRACE_BUTTON_CLICKED: "go to trace button clicked",
+ RUN_TEST_BUTTON_CLICKED: "run test button clicked",
+ JIRA_TICKET_INFO_BUTTON_CLICKED: "jira ticket info button clicked"
+ },
+ " "
+);
diff --git a/src/components/Tests/types.ts b/src/components/Tests/types.ts
new file mode 100644
index 000000000..fd68da6cf
--- /dev/null
+++ b/src/components/Tests/types.ts
@@ -0,0 +1,42 @@
+import { Duration } from "../../globals";
+import { SpanInfo } from "../../types";
+
+export interface Test {
+ name: string;
+ spanInfo: SpanInfo;
+ result: "success" | "fail" | "error";
+ runAt: string;
+ duration: Duration;
+ environment: string;
+ environmentId: string;
+ errorOrFailMessage: string | null;
+ traceId: string;
+ commitId: string | null;
+ ticketId: string | null;
+ contextsSpanCodeObjectIds: string[];
+}
+
+export interface TestsData {
+ paging: {
+ pageNumber: number;
+ pageSize: number;
+ totalCount: number;
+ };
+ spanContexts: {
+ displayName: string;
+ spanCodeObjectId: string;
+ methodCodeObjectId: string | null;
+ }[];
+ entries: Test[];
+}
+
+export interface SetSpanLatestDataPayload {
+ data: TestsData | null;
+ error: {
+ message: string;
+ } | null;
+}
+
+export interface TestsProps {
+ data?: SetSpanLatestDataPayload;
+}
diff --git a/src/components/common/App/ConfigContext.ts b/src/components/common/App/ConfigContext.ts
index 4d00b4305..b785d50d8 100644
--- a/src/components/common/App/ConfigContext.ts
+++ b/src/components/common/App/ConfigContext.ts
@@ -18,5 +18,7 @@ export const ConfigContext = createContext({
: "",
environment: isString(window.environment) ? window.environment : "",
backendInfo: undefined,
+ environments: undefined,
+ scope: undefined,
isMicrometerProject: window.isMicrometerProject === true
});
diff --git a/src/components/common/App/getTheme.ts b/src/components/common/App/getTheme.ts
index 07a990b54..2a968c607 100644
--- a/src/components/common/App/getTheme.ts
+++ b/src/components/common/App/getTheme.ts
@@ -13,6 +13,7 @@ export const grayScale = {
500: "#828599",
600: "#565966",
700: "#4c4e59",
+ 750: "#37383F",
800: "#2c2e33",
850: "#37383f",
900: "#2b2c33",
@@ -57,7 +58,11 @@ export const greenScale = {
};
const darkThemeColors: ThemeColors = {
- icon: grayScale[200],
+ icon: {
+ white: grayScale[0],
+ primary: grayScale[100],
+ disabledAlt: grayScale[500]
+ },
button: {
primary: {
background: {
@@ -234,11 +239,36 @@ const darkThemeColors: ThemeColors = {
border: grayScale[700],
icon: grayScale[200],
text: grayScale[100]
+ },
+ panel: {
+ background: grayScale[1000]
+ },
+ text: {
+ base: grayScale[0],
+ subtext: grayScale[400],
+ link: primaryScale[100],
+ success: greenScale[500]
+ },
+ surface: {
+ primaryLight: grayScale[800],
+ highlight: grayScale[750],
+ card: grayScale[1100],
+ brand: primaryScale[300],
+ secondary: grayScale[1100]
+ },
+ stroke: {
+ primary: grayScale[750],
+ secondary: grayScale[500],
+ brand: primaryScale[300]
}
};
const lightThemeColors: ThemeColors = {
- icon: grayScale[800],
+ icon: {
+ white: grayScale[0],
+ primary: grayScale[800],
+ disabledAlt: grayScale[500]
+ },
button: {
primary: {
background: {
@@ -415,6 +445,25 @@ const lightThemeColors: ThemeColors = {
border: grayScale[300],
icon: grayScale[800],
text: grayScale[800]
+ },
+ panel: { background: grayScale[150] },
+ text: {
+ base: grayScale[900],
+ subtext: grayScale[600],
+ link: primaryScale[300],
+ success: grayScale[900]
+ },
+ surface: {
+ primaryLight: grayScale[50],
+ highlight: grayScale[150],
+ card: grayScale[0],
+ brand: primaryScale[300],
+ secondary: grayScale[50]
+ },
+ stroke: {
+ primary: grayScale[500],
+ secondary: grayScale[800],
+ brand: primaryScale[300]
}
};
diff --git a/src/components/common/App/index.tsx b/src/components/common/App/index.tsx
index c3eb1690d..aefadff97 100644
--- a/src/components/common/App/index.tsx
+++ b/src/components/common/App/index.tsx
@@ -10,7 +10,13 @@ import { isString } from "../../../typeGuards/isString";
import { ConfigContext } from "./ConfigContext";
import { getTheme } from "./getTheme";
import { GlobalStyle } from "./styles";
-import { AppProps, BackendInfo, DigmaStatus } from "./types";
+import {
+ AppProps,
+ BackendInfo,
+ DigmaStatus,
+ Environment,
+ Scope
+} from "./types";
export const THEMES = ["light", "dark", "dark-jetbrains"];
@@ -181,6 +187,24 @@ export const App = (props: AppProps) => {
}
};
+ const handleSetEnvironments = (data: unknown) => {
+ if (isObject(data) && Array.isArray(data.environments)) {
+ setConfig((config) => ({
+ ...config,
+ environments: data.environments as Environment[]
+ }));
+ }
+ };
+
+ const handleSetSelectedCodeScope = (data: unknown) => {
+ if (isObject(data) && isObject(data.scope) && isString(data.scope.type)) {
+ setConfig((config) => ({
+ ...config,
+ scope: data.scope as Scope
+ }));
+ }
+ };
+
const handleSetIsMicrometerProject = (data: unknown) => {
if (isObject(data) && isBoolean(data.isMicrometerProject)) {
setConfig((config) => ({
@@ -235,6 +259,14 @@ export const App = (props: AppProps) => {
actions.SET_BACKEND_INFO,
handleSetBackendInfo
);
+ dispatcher.addActionListener(
+ actions.SET_ENVIRONMENTS,
+ handleSetEnvironments
+ );
+ dispatcher.addActionListener(
+ actions.SET_SELECTED_CODE_SCOPE,
+ handleSetSelectedCodeScope
+ );
dispatcher.addActionListener(
actions.SET_IS_MICROMETER_PROJECT,
handleSetIsMicrometerProject
@@ -292,6 +324,14 @@ export const App = (props: AppProps) => {
actions.SET_BACKEND_INFO,
handleSetBackendInfo
);
+ dispatcher.removeActionListener(
+ actions.SET_ENVIRONMENTS,
+ handleSetEnvironments
+ );
+ dispatcher.removeActionListener(
+ actions.SET_SELECTED_CODE_SCOPE,
+ handleSetSelectedCodeScope
+ );
dispatcher.removeActionListener(
actions.SET_IS_MICROMETER_PROJECT,
handleSetIsMicrometerProject
diff --git a/src/components/common/App/types.ts b/src/components/common/App/types.ts
index b7e6725d2..057f468a3 100644
--- a/src/components/common/App/types.ts
+++ b/src/components/common/App/types.ts
@@ -28,6 +28,15 @@ export enum DeploymentType {
DOCKER_EXTENSION = "DockerExtension"
}
+export interface Environment {
+ originalName: string;
+ name: string;
+}
+
+export interface Scope {
+ type: string;
+}
+
export interface ConfigContextData {
digmaApiUrl: string;
digmaStatus: DigmaStatus | undefined;
@@ -42,5 +51,7 @@ export interface ConfigContextData {
userRegistrationEmail: string;
environment: string;
backendInfo: BackendInfo | undefined;
+ environments: Environment[] | undefined;
+ scope: Scope | undefined;
isMicrometerProject: boolean;
}
diff --git a/src/components/common/JiraTicket/AttachmentTag/index.tsx b/src/components/common/JiraTicket/AttachmentTag/index.tsx
new file mode 100644
index 000000000..6d55bd2ba
--- /dev/null
+++ b/src/components/common/JiraTicket/AttachmentTag/index.tsx
@@ -0,0 +1,14 @@
+import { Tooltip } from "../../../common/Tooltip";
+import * as s from "./styles";
+import { AttachmentTagProps } from "./types";
+
+export const AttachmentTag = (props: AttachmentTagProps) => (
+
+
+
+
+
+ {props.text}
+
+
+);
diff --git a/src/components/common/JiraTicket/AttachmentTag/styles.ts b/src/components/common/JiraTicket/AttachmentTag/styles.ts
new file mode 100644
index 000000000..2e072b7bb
--- /dev/null
+++ b/src/components/common/JiraTicket/AttachmentTag/styles.ts
@@ -0,0 +1,27 @@
+import styled from "styled-components";
+
+export const Container = styled.div`
+ display: flex;
+ padding: 4px 6px 4px 4px;
+ gap: 8px;
+ border-radius: 4px;
+ border: 1px solid ${({ theme }) => theme.colors.attachmentTag.border};
+ background: ${({ theme }) => theme.colors.attachmentTag.background};
+ color: ${({ theme }) => theme.colors.attachmentTag.text};
+ align-items: center;
+ max-width: fit-content;
+`;
+
+export const IconContainer = styled.div`
+ padding: 2px;
+ border-radius: 4px;
+ color: ${({ theme }) => theme.colors.attachmentTag.icon.stroke};
+ background: ${({ theme }) => theme.colors.attachmentTag.icon.background};
+ display: flex;
+`;
+
+export const TextContainer = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
diff --git a/src/components/common/JiraTicket/AttachmentTag/types.ts b/src/components/common/JiraTicket/AttachmentTag/types.ts
new file mode 100644
index 000000000..dd182de52
--- /dev/null
+++ b/src/components/common/JiraTicket/AttachmentTag/types.ts
@@ -0,0 +1,17 @@
+import { ComponentType } from "react";
+import { IconProps } from "../../../common/icons/types";
+
+export interface AttachmentTagThemeColors {
+ background: string;
+ border: string;
+ icon: {
+ background: string;
+ stroke: string;
+ };
+ text: string;
+}
+
+export interface AttachmentTagProps {
+ icon: ComponentType;
+ text: string;
+}
diff --git a/src/components/common/JiraTicket/Field/index.tsx b/src/components/common/JiraTicket/Field/index.tsx
new file mode 100644
index 000000000..891200984
--- /dev/null
+++ b/src/components/common/JiraTicket/Field/index.tsx
@@ -0,0 +1,49 @@
+import { useCallback, useRef } from "react";
+import useDimensions from "react-cool-dimensions";
+import useScrollbarSize from "react-scrollbar-size";
+import { isString } from "../../../../typeGuards/isString";
+import * as s from "./styles";
+import { ButtonPosition, FieldProps } from "./types";
+
+export const Field = (props: FieldProps) => {
+ const scrollbar = useScrollbarSize();
+ const contentRef = useRef(null);
+ const { observe } = useDimensions();
+
+ const getContentRef = useCallback(
+ (el: HTMLDivElement | null) => {
+ observe(el);
+ contentRef.current = el;
+ },
+ [observe]
+ );
+
+ const scrollbarOffset =
+ contentRef.current &&
+ contentRef.current.scrollHeight > contentRef.current.clientHeight
+ ? scrollbar.width
+ : 0;
+
+ const iconPosition: ButtonPosition =
+ props.multiline === true ? "top" : "center";
+
+ return (
+
+ {props.label}
+
+
+ {props.content}
+
+ {props.button}
+
+
+
+ {isString(props.errorMessage) && (
+ {props.errorMessage}
+ )}
+
+ );
+};
diff --git a/src/components/common/JiraTicket/Field/styles.ts b/src/components/common/JiraTicket/Field/styles.ts
new file mode 100644
index 000000000..076cedf73
--- /dev/null
+++ b/src/components/common/JiraTicket/Field/styles.ts
@@ -0,0 +1,65 @@
+import styled from "styled-components";
+import { redScale } from "../../../common/App/getTheme";
+import { ButtonContainerProps, ContentProps } from "./types";
+
+export const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+export const Label = styled.label`
+ color: ${({ theme }) => theme.colors.jiraTicket.text.secondary};
+`;
+
+export const ContentContainer = styled.div`
+ display: flex;
+ border-radius: 4px;
+ border: 1px solid ${({ theme }) => theme.colors.field.border};
+ color: ${({ theme }) => theme.colors.field.text};
+ position: relative;
+`;
+
+export const Content = styled.div`
+ width: 100%;
+ max-height: 200px;
+ padding: 6px 28px 6px 8px;
+ overflow: ${({ $multiline }) => ($multiline ? "auto" : "hidden")};
+ white-space: ${({ $multiline }) => ($multiline ? "pre-line" : "nowrap")};
+ ${({ $multiline }) =>
+ $multiline ? "word-wrap: break-word" : "text-overflow: ellipsis"};
+`;
+
+export const ButtonContainer = styled.div`
+ position: absolute;
+ right: ${({ $scrollbarOffset }) => $scrollbarOffset + 4}px;
+ ${({ $position }) => {
+ switch ($position) {
+ case "top":
+ return "top: 4px;";
+ case "center":
+ return `
+ top: 0;
+ bottom: 0;
+ margin: auto;
+ height: fit-content;
+ `;
+ }
+ }}
+`;
+
+export const ErrorMessage = styled.span`
+ display: flex;
+ font-size: 13px;
+ align-items: center;
+ white-space: pre-line;
+ color: ${({ theme }) => {
+ switch (theme.mode) {
+ case "light":
+ return redScale[500];
+ case "dark":
+ case "dark-jetbrains":
+ return redScale[300];
+ }
+ }};
+`;
diff --git a/src/components/common/JiraTicket/Field/types.ts b/src/components/common/JiraTicket/Field/types.ts
new file mode 100644
index 000000000..d3e635fd4
--- /dev/null
+++ b/src/components/common/JiraTicket/Field/types.ts
@@ -0,0 +1,26 @@
+import { ReactNode } from "react";
+
+export interface FieldThemeColors {
+ border: string;
+ icon: string;
+ text: string;
+}
+
+export type ButtonPosition = "top" | "center";
+
+export interface FieldProps {
+ content: ReactNode;
+ label: string;
+ button: ReactNode;
+ multiline?: boolean;
+ errorMessage?: string;
+}
+
+export interface ButtonContainerProps {
+ $position: ButtonPosition;
+ $scrollbarOffset: number;
+}
+
+export interface ContentProps {
+ $multiline?: boolean;
+}
diff --git a/src/components/common/JiraTicket/IconButton/index.tsx b/src/components/common/JiraTicket/IconButton/index.tsx
new file mode 100644
index 000000000..1124f2f23
--- /dev/null
+++ b/src/components/common/JiraTicket/IconButton/index.tsx
@@ -0,0 +1,11 @@
+import { Tooltip } from "../../../common/Tooltip";
+import * as s from "./styles";
+import { IconButtonProps } from "./types";
+
+export const IconButton = (props: IconButtonProps) => (
+
+
+
+
+
+);
diff --git a/src/components/common/JiraTicket/IconButton/styles.ts b/src/components/common/JiraTicket/IconButton/styles.ts
new file mode 100644
index 000000000..faff4261e
--- /dev/null
+++ b/src/components/common/JiraTicket/IconButton/styles.ts
@@ -0,0 +1,11 @@
+import styled from "styled-components";
+
+export const Button = styled.button`
+ background: none;
+ border: none;
+ margin: 0;
+ padding: 4px;
+ cursor: ${({ disabled }) => (disabled ? "auto" : "pointer")};
+ display: flex;
+ color: ${({ theme }) => theme.colors.field.icon};
+`;
diff --git a/src/components/common/JiraTicket/IconButton/types.ts b/src/components/common/JiraTicket/IconButton/types.ts
new file mode 100644
index 000000000..b3a6a8095
--- /dev/null
+++ b/src/components/common/JiraTicket/IconButton/types.ts
@@ -0,0 +1,9 @@
+import { ComponentType } from "react";
+import { IconProps } from "../../../common/icons/types";
+
+export interface IconButtonProps {
+ icon: ComponentType;
+ onClick: () => void;
+ title: string;
+ disabled?: boolean;
+}
diff --git a/src/components/common/JiraTicket/JiraTicket.stories.tsx b/src/components/common/JiraTicket/JiraTicket.stories.tsx
new file mode 100644
index 000000000..b35dc8b9e
--- /dev/null
+++ b/src/components/common/JiraTicket/JiraTicket.stories.tsx
@@ -0,0 +1,24 @@
+import { Meta, StoryObj } from "@storybook/react";
+import { JiraTicket } from ".";
+
+// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
+const meta: Meta = {
+ title: "common/JiraTicket",
+ component: JiraTicket,
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
+ layout: "fullscreen"
+ }
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ summary: "Summary text",
+ description: { content: "Multiline\ndescription text", isLoading: false },
+ attachment: { url: "https://www.example.com", fileName: "attachment.ext" }
+ }
+};
diff --git a/src/components/common/JiraTicket/index.tsx b/src/components/common/JiraTicket/index.tsx
new file mode 100644
index 000000000..f28ae3564
--- /dev/null
+++ b/src/components/common/JiraTicket/index.tsx
@@ -0,0 +1,175 @@
+import copy from "copy-to-clipboard";
+import { useRef, useState } from "react";
+import { useTheme } from "styled-components";
+import { DefaultTheme } from "styled-components/dist/types";
+import { isString } from "../../../typeGuards/isString";
+import { addPrefix } from "../../../utils/addPrefix";
+import { downloadFile } from "../../../utils/downloadFile";
+import { sendTrackingEvent } from "../../../utils/sendTrackingEvent";
+import { CircleLoader } from "../../common/CircleLoader";
+import { CircleLoaderColors } from "../../common/CircleLoader/types";
+import { IconTag } from "../../common/IconTag";
+import { Tooltip } from "../../common/Tooltip";
+import { CopyIcon } from "../../common/icons/12px/CopyIcon";
+import { CrossIcon } from "../../common/icons/12px/CrossIcon";
+import { DownloadIcon } from "../../common/icons/12px/DownloadIcon";
+import { PaperclipIcon } from "../../common/icons/12px/PaperclipIcon";
+import { JiraLogoIcon } from "../../common/icons/16px/JiraLogoIcon";
+import { AttachmentTag } from "./AttachmentTag";
+import { Field } from "./Field";
+import { IconButton } from "./IconButton";
+import * as s from "./styles";
+import { trackingEvents } from "./tracking";
+import { JiraTicketProps } from "./types";
+
+const getCircleLoaderColors = (theme: DefaultTheme): CircleLoaderColors => {
+ switch (theme.mode) {
+ case "light":
+ return {
+ start: "rgb(81 84 236 / 0%)",
+ end: "#5154ec",
+ background: "#fff"
+ };
+ case "dark":
+ case "dark-jetbrains":
+ return {
+ start: "rgb(120 145 208 / 0%)",
+ end: "#7891d0",
+ background: "#222326"
+ };
+ }
+};
+
+export const JiraTicket = (props: JiraTicketProps) => {
+ const [downloadErrorMessage, setDownloadErrorMessage] = useState();
+ const descriptionContentRef = useRef(null);
+ const theme = useTheme();
+
+ const prefixedTrackingEvents = addPrefix(
+ props.tracking?.prefix || "",
+ trackingEvents,
+ " "
+ );
+
+ const handleCloseButtonClick = () => {
+ props.onClose();
+ };
+
+ const copyToClipboard = (
+ field: string,
+ value: HTMLElement | null | string
+ ) => {
+ sendTrackingEvent(
+ prefixedTrackingEvents.JIRA_TICKET_FIELD_COPY_BUTTON_CLICKED,
+ {
+ ...(props.tracking?.additionalInfo || {}),
+ field
+ }
+ );
+
+ if (value === null) {
+ return;
+ }
+
+ if (isString(value)) {
+ copy(value);
+ } else {
+ copy(value.innerText);
+ }
+ };
+
+ const handleDownloadButtonClick = () => {
+ sendTrackingEvent(
+ prefixedTrackingEvents.JIRA_TICKET_ATTACHMENT_DOWNLOAD_BUTTON_CLICKED,
+ { ...(props.tracking?.additionalInfo || {}) }
+ );
+
+ if (props.attachment) {
+ downloadFile(props.attachment.url, props.attachment.fileName).catch(
+ (e) => {
+ const errorMessageString =
+ e instanceof Error ? `Error: ${e.message}` : "";
+ setDownloadErrorMessage(
+ `Failed to download file.\n${errorMessageString}`
+ );
+ }
+ );
+ }
+ };
+
+ return (
+
+
+
+
+ Create Jira Ticket
+ Bug details
+
+
+
+
+
+
+
+ copyToClipboard("summary", props.summary)}
+ />
+ }
+ />
+
+ {props.description.isLoading ? (
+
+
+
+ ) : (
+ props.description.content
+ )}
+
+ }
+ errorMessage={props.description.errorMessage}
+ button={
+
+ copyToClipboard("description", descriptionContentRef.current)
+ }
+ />
+ }
+ />
+ {props.attachment && (
+
+ }
+ button={
+
+ }
+ errorMessage={downloadErrorMessage}
+ />
+ )}
+
+ );
+};
diff --git a/src/components/common/JiraTicket/styles.ts b/src/components/common/JiraTicket/styles.ts
new file mode 100644
index 000000000..a6c84d9eb
--- /dev/null
+++ b/src/components/common/JiraTicket/styles.ts
@@ -0,0 +1,49 @@
+import styled from "styled-components";
+
+export const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ border-radius: 7px;
+ border: 1px solid ${({ theme }) => theme.colors.jiraTicket.border};
+ background: ${({ theme }) => theme.colors.jiraTicket.background};
+ box-shadow: 0 1px 4px 0 rgb(0 0 0 / 45%);
+ padding: 12px;
+ gap: 12px;
+ font-size: 14px;
+ width: 100%;
+ box-sizing: border-box;
+`;
+
+export const Header = styled.div`
+ display: flex;
+ gap: 12px;
+`;
+
+export const TitleContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ color: ${({ theme }) => theme.colors.jiraTicket.text.secondary};
+`;
+
+export const Title = styled.span`
+ color: ${({ theme }) => theme.colors.jiraTicket.text.primary};
+`;
+
+export const CloseButton = styled.button`
+ background: none;
+ border: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ cursor: pointer;
+ margin-left: auto;
+ height: fit-content;
+ color: ${({ theme }) => theme.colors.jiraTicket.icon};
+`;
+
+export const LoaderContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 200px;
+`;
diff --git a/src/components/common/JiraTicket/tracking.ts b/src/components/common/JiraTicket/tracking.ts
new file mode 100644
index 000000000..b5399a538
--- /dev/null
+++ b/src/components/common/JiraTicket/tracking.ts
@@ -0,0 +1,6 @@
+export const trackingEvents = {
+ JIRA_TICKET_FIELD_COPY_BUTTON_CLICKED:
+ "jira ticket field copy button clicked",
+ JIRA_TICKET_ATTACHMENT_DOWNLOAD_BUTTON_CLICKED:
+ "jira ticket attachment download button clicked"
+};
diff --git a/src/components/common/JiraTicket/types.ts b/src/components/common/JiraTicket/types.ts
new file mode 100644
index 000000000..06796cba5
--- /dev/null
+++ b/src/components/common/JiraTicket/types.ts
@@ -0,0 +1,26 @@
+import { ReactNode } from "react";
+
+export interface JiraTicketThemeColors {
+ background: string;
+ border: string;
+ text: {
+ primary: string;
+ secondary: string;
+ };
+ icon: string;
+}
+
+export interface JiraTicketProps {
+ summary: string;
+ description: {
+ content: ReactNode;
+ isLoading?: boolean;
+ errorMessage?: string;
+ };
+ attachment?: { url: string; fileName: string };
+ onClose: () => void;
+ tracking?: {
+ prefix?: string;
+ additionalInfo?: Record;
+ };
+}
diff --git a/src/components/common/NewButton/index.tsx b/src/components/common/NewButton/index.tsx
index bca665bd6..f107fca7f 100644
--- a/src/components/common/NewButton/index.tsx
+++ b/src/components/common/NewButton/index.tsx
@@ -1,13 +1,18 @@
+import { ForwardedRef, forwardRef } from "react";
import * as s from "./styles";
import { NewButtonProps } from "./types";
-export const NewButton = (props: NewButtonProps) => {
+export const NewButtonComponent = (
+ props: NewButtonProps,
+ ref: ForwardedRef
+) => {
const buttonType = props.buttonType || "primary";
const buttonSize = props.size || "small";
const iconSize = buttonSize === "large" ? 16 : 13;
return (
{
);
};
+
+export const NewButton = forwardRef(NewButtonComponent);
diff --git a/src/components/common/icons/12px/TimerIcon.tsx b/src/components/common/icons/12px/TimerIcon.tsx
new file mode 100644
index 000000000..04199ea7b
--- /dev/null
+++ b/src/components/common/icons/12px/TimerIcon.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { useIconProps } from "../hooks";
+import { IconProps } from "../types";
+
+const TimerIconComponent = (props: IconProps) => {
+ const { size, color } = useIconProps(props);
+
+ return (
+
+ );
+};
+
+export const TimerIcon = React.memo(TimerIconComponent);
diff --git a/src/components/common/icons/16px/TimerIcon.tsx b/src/components/common/icons/16px/TimerIcon.tsx
new file mode 100644
index 000000000..f2a6b54da
--- /dev/null
+++ b/src/components/common/icons/16px/TimerIcon.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import { useIconProps } from "../hooks";
+import { IconProps } from "../types";
+
+const TimerIconComponent = (props: IconProps) => {
+ const { size, color } = useIconProps(props);
+
+ return (
+
+ );
+};
+
+export const TimerIcon = React.memo(TimerIconComponent);
diff --git a/src/containers/Tests/index.tsx b/src/containers/Tests/index.tsx
new file mode 100644
index 000000000..4db1c4e0c
--- /dev/null
+++ b/src/containers/Tests/index.tsx
@@ -0,0 +1,27 @@
+import { createRoot } from "react-dom/client";
+import {
+ cancelMessage,
+ initializeDigmaMessageListener,
+ sendMessage
+} from "../../api";
+import { Tests } from "../../components/Tests";
+import { App } from "../../components/common/App";
+import { dispatcher } from "../../dispatcher";
+import { GlobalStyle } from "./styles";
+
+initializeDigmaMessageListener(dispatcher);
+
+window.sendMessageToDigma = sendMessage;
+window.cancelMessageToDigma = cancelMessage;
+
+const rootElement = document.getElementById("root");
+
+if (rootElement) {
+ const root = createRoot(rootElement);
+ root.render(
+
+
+
+
+ );
+}
diff --git a/src/containers/Tests/styles.ts b/src/containers/Tests/styles.ts
new file mode 100644
index 000000000..52176c92a
--- /dev/null
+++ b/src/containers/Tests/styles.ts
@@ -0,0 +1,7 @@
+import { createGlobalStyle } from "styled-components";
+
+export const GlobalStyle = createGlobalStyle`
+ body {
+ background: ${({ theme }) => theme.colors.panel.background};
+ }
+`;
diff --git a/src/featureFlags.ts b/src/featureFlags.ts
index e1ed0fbfa..18a9532a9 100644
--- a/src/featureFlags.ts
+++ b/src/featureFlags.ts
@@ -7,7 +7,7 @@ const featureFlagMinBackendVersions: Record = {
"v0.2.172-alpha.8",
[FeatureFlag.IS_ASSETS_SERVICE_FILTER_VISIBLE]: "v0.2.174",
[FeatureFlag.IS_ASSETS_OVERALL_IMPACT_HIDDEN]: "v0.2.181-alpha.1",
- [FeatureFlag.IS_TICKET_LINK_UNLINK_INPUT_ENABLED]: "v0.2.200"
+ [FeatureFlag.IS_INSIGHT_TICKET_LINKAGE_ENABLED]: "v0.2.200"
};
export const getFeatureFlagValue = (
diff --git a/src/globals.d.ts b/src/globals.d.ts
index a150b95cb..306251f02 100644
--- a/src/globals.d.ts
+++ b/src/globals.d.ts
@@ -51,6 +51,7 @@ declare global {
recentActivityExpirationLimit?: unknown;
recentActivityDocumentationURL?: unknown;
recentActivityIsEnvironmentManagementEnabled?: unknown;
+ testsRefreshInterval?: unknown;
wizardSkipInstallationStep?: unknown;
wizardFirstLaunch?: unknown;
}
diff --git a/src/styled.d.ts b/src/styled.d.ts
index 3c32f094e..bd527b23e 100644
--- a/src/styled.d.ts
+++ b/src/styled.d.ts
@@ -12,7 +12,11 @@ import { TooltipThemeColors } from "./components/common/Tooltip/types";
import { Mode } from "./globals";
export interface ThemeColors {
- icon: string;
+ icon: {
+ white: string;
+ primary: string;
+ disabledAlt: string;
+ };
button: {
primary: ButtonThemeColors;
secondary: ButtonThemeColors;
@@ -31,6 +35,27 @@ export interface ThemeColors {
attachmentTag: AttachmentTagThemeColors;
jiraTicket: JiraTicketThemeColors;
field: FieldThemeColors;
+ panel: {
+ background: string;
+ };
+ text: {
+ base: string;
+ link: string;
+ subtext: string;
+ success: string;
+ };
+ surface: {
+ primaryLight: string;
+ highlight: string;
+ card: string;
+ brand: string;
+ secondary: string;
+ };
+ stroke: {
+ primary: string;
+ secondary: string;
+ brand: string;
+ };
}
declare module "styled-components" {
diff --git a/src/types.ts b/src/types.ts
index 5807c044e..6be3fb897 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,7 +4,7 @@ export enum FeatureFlag {
IS_DASHBOARD_CLIENT_SPANS_OVERALL_IMPACT_ENABLED,
IS_ASSETS_SERVICE_FILTER_VISIBLE,
IS_ASSETS_OVERALL_IMPACT_HIDDEN,
- IS_TICKET_LINK_UNLINK_INPUT_ENABLED
+ IS_INSIGHT_TICKET_LINKAGE_ENABLED
}
export enum InsightType {
diff --git a/src/utils/getDurationString.ts b/src/utils/getDurationString.ts
new file mode 100644
index 000000000..fafb731d0
--- /dev/null
+++ b/src/utils/getDurationString.ts
@@ -0,0 +1,4 @@
+import { Duration } from "../globals";
+
+export const getDurationString = (duration: Duration) =>
+ `${duration.value} ${duration.unit}`;
diff --git a/webpackEntries.ts b/webpackEntries.ts
index d99d5c055..7c9457cc7 100644
--- a/webpackEntries.ts
+++ b/webpackEntries.ts
@@ -43,6 +43,10 @@ export const entries: AppEntries = {
"recentActivityIsEnvironmentManagementEnabled"
]
},
+ tests: {
+ entry: path.resolve(__dirname, "./src/containers/Tests/index.tsx"),
+ environmentVariables: ["testsRefreshInterval"]
+ },
troubleshooting: {
entry: path.resolve(__dirname, "./src/containers/Troubleshooting/index.tsx")
}