Repository: kernelci/dashboard
Pull request #1139: feat(test-details): add link to hardware details
Author: login: murilx
Created: 2025-03-31T12:49:35Z | Merged: 2025-03-31T17:31:14Z

Build has no direct relation with hardware so that's why I didn't add any link to it as well. Even 'platform' that's inside the `misc` field in the `builds` table has no direct relation with the available hardware details pages, since we only consider platform and compatibles of tests in this case

Closes #960

## How to test
- Enter a page with environment compatible: http://localhost:5173/test/maestro%3A67ea51dc67593f2aa0417959
- Enter a page with platform and no environment compatible: http://localhost:5173/test/maestro%3A67e9d6b667593f2aa0405adb
- Enter a page with none: http://localhost:5173/test/redhat%3A1739009469-aarch64-kernel-automotive_upt_aws_5?o=redhat

Linked issue #960: add missing links to tests and build details
Currently test details has a link to the tree details it's from. Build details should have a link to its tree as well. Also, add links to hardware details of both of them

Diff:
diff --git a/dashboard/src/components/TestDetails/TestDetails.tsx b/dashboard/src/components/TestDetails/TestDetails.tsx
index 7c3fa8b0b..d656d4b2e 100644
--- a/dashboard/src/components/TestDetails/TestDetails.tsx
+++ b/dashboard/src/components/TestDetails/TestDetails.tsx
@@ -1,4 +1,5 @@
 import { FormattedMessage, useIntl } from 'react-intl';
+import { roundToNearestMinutes } from 'date-fns';
 
 import { Fragment, useCallback, useMemo, useState } from 'react';
 import type { Dispatch, SetStateAction, JSX } from 'react';
@@ -15,7 +16,12 @@ import { MdChevronRight } from 'react-icons/md';
 
 import type { UseQueryResult } from '@tanstack/react-query';
 
-import { shouldTruncate, truncateBigText, valueOrEmpty } from '@/lib/string';
+import {
+  EMPTY_VALUE,
+  shouldTruncate,
+  truncateBigText,
+  valueOrEmpty,
+} from '@/lib/string';
 import type { TTestDetails, TestStatusHistory } from '@/types/tree/TestDetails';
 import { Sheet } from '@/components/Sheet';
 import {
@@ -60,6 +66,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/Tooltip';
 import MemoizedLinkItem from '@/components/DetailsLink';
 import { processLogData } from '@/hooks/useLogData';
 
+import { dateObjectToTimestampInSeconds, daysToSeconds } from '@/utils/date';
+import { REDUCED_TIME_SEARCH } from '@/utils/constants/general';
+
 import { StatusHistoryItem } from './StatusHistoryItem';
 
 const TestDetailsSections = ({
@@ -138,6 +147,76 @@ const TestDetailsSections = ({
     test.tree_name,
   ]);
 
+  const endTimestampInSeconds = dateObjectToTimestampInSeconds(
+    roundToNearestMinutes(new Date(), {
+      nearestTo: 30,
+    }),
+  );
+  const startTimestampInSeconds =
+    endTimestampInSeconds - daysToSeconds(REDUCED_TIME_SEARCH);
+
+  const hardwareDetailsLink = useMemo(() => {
+    if (hardware === formatMessage({ id: 'global.unknown' })) {
+      return <span>{hardware}</span>;
+    }
+
+    return (
+      <MemoizedLinkItem
+        to="/hardware/$hardwareId"
+        params={{ hardwareId: hardware }}
+        state={s => s}
+        search={{
+          origin: searchParams.origin,
+          startTimestampInSeconds: startTimestampInSeconds,
+          endTimestampInSeconds: endTimestampInSeconds,
+        }}
+      >
+        {hardware}
+        <LinkIcon className="text-blue text-xl" />
+      </MemoizedLinkItem>
+    );
+  }, [
+    searchParams,
+    hardware,
+    formatMessage,
+    startTimestampInSeconds,
+    endTimestampInSeconds,
+  ]);
+
+  const compatiblesLink = useMemo(() => {
+    if (!test.environment_compatible) {
+      return <span>{EMPTY_VALUE}</span>;
+    }
+
+    return (
+      <div className="flex flex-wrap gap-2">
+        {test.environment_compatible.map(
+          (compatible: string): JSX.Element => (
+            <MemoizedLinkItem
+              key={compatible}
+              to="/hardware/$hardwareId"
+              params={{ hardwareId: compatible }}
+              state={s => s}
+              search={{
+                origin: searchParams.origin,
+                startTimestampInSeconds: startTimestampInSeconds,
+                endTimestampInSeconds: endTimestampInSeconds,
+              }}
+            >
+              {compatible}
+              <LinkIcon className="text-blue text-xl" />
+            </MemoizedLinkItem>
+          ),
+        )}
+      </div>
+    );
+  }, [
+    searchParams,
+    test.environment_compatible,
+    startTimestampInSeconds,
+    endTimestampInSeconds,
+  ]);
+
   const setSheetToLog = useCallback(
     (): void => setSheetType('log'),
     [setSheetType],
@@ -311,6 +390,7 @@ const TestDetailsSections = ({
             {
               title: 'global.hardware',
               linkText: hardware,
+              linkComponent: hardwareDetailsLink,
             },
             {
               title: 'global.architecture',
@@ -348,6 +428,7 @@ const TestDetailsSections = ({
             {
               title: 'global.compatibles',
               linkText: valueOrEmpty(test.environment_compatible?.join(' | ')),
+              linkComponent: compatiblesLink,
             },
             {
               title: 'testDetails.testId',
@@ -375,6 +456,8 @@ const TestDetailsSections = ({
     formatMessage,
     setSheetToLog,
     treeDetailsLink,
+    hardwareDetailsLink,
+    compatiblesLink,
     buildDetailsLink,
     hardware,
   ]);
diff --git a/dashboard/src/lib/string.ts b/dashboard/src/lib/string.ts
index cc9802e83..9a0e7e4d8 100644
--- a/dashboard/src/lib/string.ts
+++ b/dashboard/src/lib/string.ts
@@ -1,6 +1,8 @@
+export const EMPTY_VALUE = '-';
+
 export const valueOrEmpty = (
   value: string | undefined,
-  emptyValue = '-',
+  emptyValue = EMPTY_VALUE,
 ): string => value || emptyValue;
 
 export const shouldTruncate = (value: string, maxLength = 50): boolean =>


Reviews:
user: login: MarceloRobert
body: I think it would be better if each hardware or platform would direct to the respective page, so the "hardware" field would direct to the platform, if it exists, or to the first compatible. And the compatibles field would direct to each compatible page for each string, if that's possible
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T14:08:41Z
user: login: MarceloRobert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T16:27:01Z
user: login: murilx
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T17:07:16Z
user: login: MarceloRobert
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-31T17:30:37Z

Review comments:
diff_hunk: @@ -138,6 +141,49 @@ const TestDetailsSections = ({
     test.tree_name,
   ]);
 
+  const hardwareDetailsLink = useMemo(() => {
+    // eslint-disable-next-line no-magic-numbers
+    const LAST_7_DAYS = 7 * 24 * 60 * 60;
path: dashboard/src/components/TestDetails/TestDetails.tsx
user: login: MarceloRobert
body: - This could be a const in another file
- The variable doesn't exactly relate to the _last_ 7 days, but rather to 7 days in seconds
- The default time interval for hardware is 5 days, not 7
created_at: 2025-03-31T14:06:37Z
author_association: COLLABORATOR
diff_hunk: @@ -138,6 +142,76 @@ const TestDetailsSections = ({
     test.tree_name,
   ]);
 
+  const endTimestampInSeconds = dateObjectToTimestampInSeconds(
+    roundToNearestMinutes(new Date(), {
+      nearestTo: 30,
+    }),
+  );
+  const startTimestampInSeconds =
+    endTimestampInSeconds - daysToSeconds(REDUCED_TIME_SEARCH);
+
+  const hardwareDetailsLink = useMemo(() => {
+    if (hardware === formatMessage({ id: 'global.unknown' })) {
+      return <span>{hardware}</span>;
+    }
+
+    return (
+      <MemoizedLinkItem
+        to="/hardware/$hardwareId"
+        params={{ hardwareId: hardware }}
+        state={s => s}
+        search={{
+          origin: searchParams.origin,
+          startTimestampInSeconds: startTimestampInSeconds,
+          endTimestampInSeconds: endTimestampInSeconds,
+        }}
+      >
+        {hardware}
+        <LinkIcon className="text-blue text-xl" />
+      </MemoizedLinkItem>
+    );
+  }, [
+    searchParams,
+    hardware,
+    formatMessage,
+    startTimestampInSeconds,
+    endTimestampInSeconds,
+  ]);
+
+  const compatiblesLink = useMemo(() => {
+    if (!test.environment_compatible) {
+      return <span>{valueOrEmpty(test.environment_compatible, '-')}</span>;
+    }
path: dashboard/src/components/TestDetails/TestDetails.tsx
user: login: MarceloRobert
body: if test.environment_compatible doesn't exist, you don't need to pass it to valueOrEmpty
created_at: 2025-03-31T16:20:28Z
author_association: COLLABORATOR
diff_hunk: @@ -138,6 +142,76 @@ const TestDetailsSections = ({
     test.tree_name,
   ]);
 
+  const endTimestampInSeconds = dateObjectToTimestampInSeconds(
+    roundToNearestMinutes(new Date(), {
+      nearestTo: 30,
+    }),
+  );
+  const startTimestampInSeconds =
+    endTimestampInSeconds - daysToSeconds(REDUCED_TIME_SEARCH);
+
+  const hardwareDetailsLink = useMemo(() => {
+    if (hardware === formatMessage({ id: 'global.unknown' })) {
+      return <span>{hardware}</span>;
+    }
+
+    return (
+      <MemoizedLinkItem
+        to="/hardware/$hardwareId"
+        params={{ hardwareId: hardware }}
+        state={s => s}
+        search={{
+          origin: searchParams.origin,
+          startTimestampInSeconds: startTimestampInSeconds,
+          endTimestampInSeconds: endTimestampInSeconds,
+        }}
+      >
+        {hardware}
+        <LinkIcon className="text-blue text-xl" />
+      </MemoizedLinkItem>
+    );
+  }, [
+    searchParams,
+    hardware,
+    formatMessage,
+    startTimestampInSeconds,
+    endTimestampInSeconds,
+  ]);
+
+  const compatiblesLink = useMemo(() => {
+    if (!test.environment_compatible) {
+      return <span>{valueOrEmpty(test.environment_compatible, '-')}</span>;
+    }
+
+    return (
+      <>
+        {test.environment_compatible.map(
+          (compatible: string): JSX.Element => (
path: dashboard/src/components/TestDetails/TestDetails.tsx
user: login: MarceloRobert
body: IMO it would be better to leave them in a single line
```suggestion
    return (
      <div className="flex flex-wrap gap2">
        {test.environment_compatible.map(
          (compatible: string): JSX.Element => (
```
created_at: 2025-03-31T16:26:00Z
author_association: COLLABORATOR
diff_hunk: @@ -138,6 +142,76 @@ const TestDetailsSections = ({
     test.tree_name,
   ]);
 
+  const endTimestampInSeconds = dateObjectToTimestampInSeconds(
+    roundToNearestMinutes(new Date(), {
+      nearestTo: 30,
+    }),
+  );
+  const startTimestampInSeconds =
+    endTimestampInSeconds - daysToSeconds(REDUCED_TIME_SEARCH);
+
+  const hardwareDetailsLink = useMemo(() => {
+    if (hardware === formatMessage({ id: 'global.unknown' })) {
+      return <span>{hardware}</span>;
+    }
+
+    return (
+      <MemoizedLinkItem
+        to="/hardware/$hardwareId"
+        params={{ hardwareId: hardware }}
+        state={s => s}
+        search={{
+          origin: searchParams.origin,
+          startTimestampInSeconds: startTimestampInSeconds,
+          endTimestampInSeconds: endTimestampInSeconds,
+        }}
+      >
+        {hardware}
+        <LinkIcon className="text-blue text-xl" />
+      </MemoizedLinkItem>
+    );
+  }, [
+    searchParams,
+    hardware,
+    formatMessage,
+    startTimestampInSeconds,
+    endTimestampInSeconds,
+  ]);
+
+  const compatiblesLink = useMemo(() => {
+    if (!test.environment_compatible) {
+      return <span>{valueOrEmpty(test.environment_compatible, '-')}</span>;
+    }
path: dashboard/src/components/TestDetails/TestDetails.tsx
user: login: murilx
body: I wanted to use the default value of 'valueOrEmpty' function, so that if we - for some reason - change it in the future, we won't need to change it manually here as well. I did make it wrong though by passing '-' directly to the function
created_at: 2025-03-31T17:07:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: murilx
created_at: 2025-03-31T14:11:43Z
body: > I think it would be better if each hardware or platform would direct to the respective page, so the "hardware" field would direct to the platform, if it exists, or to the first compatible. And the compatibles field would direct to each compatible page for each string, if that's possible

I can't think of a way to guarantee that a hardware details page for a platform exists if the same page has a non null environment_compatible. That is because the logic for a hardware details page is environment_compatible first. So, if for example every test for the platform `A` also has all valid environment_compatibles, than a page for `A` won't exist. Unless I'm misremembering something
author_association: CONTRIBUTOR
user: login: murilx
created_at: 2025-03-31T14:33:23Z
body: I did misremember. This logic I described applies to shown hardware in hardware listing but there still exist pages for all test platforms
author_association: CONTRIBUTOR