diff --git a/src/components/composite/Catalog/CatalogCard.module.css b/src/components/composite/Catalog/CatalogCard.module.css new file mode 100644 index 0000000..2f483d4 --- /dev/null +++ b/src/components/composite/Catalog/CatalogCard.module.css @@ -0,0 +1,70 @@ +.cardContainer * { + padding: 0; + margin: 0; +} + +.cardContainer { + display: flex; + flex-direction: column; + border-radius: 8px; + padding: 12px; + border: 1px solid #00000027; + background: white; + gap: 32px; +} + +.infoContainer { + display: flex; + flex-direction: column; + gap: 8px; +} + +.title { + font-size: 20px; +} + +.description { + font-size: var(--font-size-base); + font-weight: 400; +} + +.date { + font-size: var(--font-size-small); + font-weight: 400; + color: #0000009b; +} + +.footerContainer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tag { + font-size: var(--font-size-base); + padding: 2px 6px; + border-radius: 3px; + color: white; +} + +.tagAPI { + background-color: #FFC5003B; + color: #A16B00; +} + +.tagCatalog { + background-color: #00000010; + color: #0000009B; +} + +.browseButton { + color: #202020; + font-size: var(--font-size-base); +} + +.button { + border: none; + text-decoration: underline; + cursor: pointer; + background: none; +} \ No newline at end of file diff --git a/src/components/composite/Catalog/CatalogCard.test.tsx b/src/components/composite/Catalog/CatalogCard.test.tsx new file mode 100644 index 0000000..309a905 --- /dev/null +++ b/src/components/composite/Catalog/CatalogCard.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CatalogCard } from "./CatalogCard"; +import { BaseCatalog } from "./types"; + +const shortDescription = "Lorem ipsum dolor sit amet"; +const longDescription = + "Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus in quam quod amet eaque rem, sit ex consequatur delectus porro a aliquid neque aliquam illum odit fuga libero suscipit quisquam."; + +const shortCatalog: BaseCatalog = { + id: "1", + title: "Catalog 1", + description: shortDescription, + temporalExtent: [new Date(Date.UTC(2024, 1, 1)), new Date(Date.UTC(2025, 1, 1))], + indicatorTag: "API", +}; + +const longCatalog: BaseCatalog = { + id: "2", + title: "Catalog 2", + description: longDescription, + temporalExtent: [new Date(Date.UTC(2024, 1, 1)), new Date(Date.UTC(2025, 1, 1))], +}; + +describe("CatalogCard", () => { + it("renders all components correctly", async () => { + render(); + expect(screen.getByText("Catalog 1")).toBeInTheDocument(); + expect( + screen.getByText( + "2/1/2024, 12:00:00 AM UTC - 2/1/2025, 12:00:00 AM UTC", + ), + ).toBeInTheDocument(); + expect(screen.getByText(shortDescription)).toBeInTheDocument(); + expect(screen.queryByText("Read More")).not.toBeInTheDocument(); + expect(screen.queryByText("Browse")).toBeInTheDocument(); + expect(screen.queryByText("API")).toBeInTheDocument(); + }); + + it("displays full and truncated description", async () => { + const user = userEvent.setup(); + const maxLength = 10; + render( + , + ); + expect(screen.getByText("Catalog 2")).toBeInTheDocument(); + expect( + screen.getByText(`${longDescription.slice(0, maxLength)}...`), + ).toBeInTheDocument(); + const readMoreButton = screen.getByRole("button", { + name: "Read more", + }); + expect(readMoreButton).toHaveTextContent("Read More"); + await user.click(readMoreButton); + expect(readMoreButton).toHaveTextContent("Read Less"); + expect(screen.getByText(`${longDescription}`)).toBeInTheDocument(); + }); + + it("renders one date when only one date is provided", () => { + const date = new Date(Date.UTC(2024, 1, 1, 9, 0, 0)); + render(); + expect( + screen.getByText("2/1/2024, 9:00:00 AM UTC"), + ).toBeInTheDocument(); + }); + + it("renders one date when two dates are the same", () => { + const date1 = new Date(Date.UTC(2024, 1, 1, 0, 0, 0)); + const date2 = new Date(Date.UTC(2024, 1, 1, 0, 0, 0)); + render( + , + ); + expect( + screen.getByText("2/1/2024, 12:00:00 AM UTC"), + ).toBeInTheDocument(); + }); + + it("renders custom description", () => { + render( +

Custom Description

} + />, + ); + expect(screen.getByText("Custom Description")).toBeInTheDocument(); + }); +}); diff --git a/src/components/composite/Catalog/CatalogCard.tsx b/src/components/composite/Catalog/CatalogCard.tsx new file mode 100644 index 0000000..98badf4 --- /dev/null +++ b/src/components/composite/Catalog/CatalogCard.tsx @@ -0,0 +1,103 @@ +import { useMemo, useState, JSX } from "react"; +import { Button } from "react-aria-components"; + +import type { PressEvent } from "react-aria"; +import styles from "./CatalogCard.module.css"; +import { convertDateToUTCString, truncateText } from "../../../utils"; +import { BaseCatalog, IndicatorTag } from "./types"; + +export interface CatalogCardProps extends BaseCatalog { + onBrowsePress?: (e: PressEvent) => void; + maxDescriptionLength?: number; + renderDescription?: (description: string) => JSX.Element; +} + +const MAX_LENGTH = 250; + +const Tag = ({ indicatorTag }: { indicatorTag: IndicatorTag }) => ( +
+ {indicatorTag} +
+); + +export const CatalogCard = ({ + id, + title, + description: initialDescription, + temporalExtent, + indicatorTag, + maxDescriptionLength = MAX_LENGTH, + onBrowsePress, + renderDescription, +}: CatalogCardProps) => { + const [shouldTruncateDescription, setShouldTruncateDescription] = + useState(true); + + const description = useMemo( + () => + shouldTruncateDescription + ? truncateText(initialDescription, maxDescriptionLength) + : initialDescription, + [initialDescription, maxDescriptionLength, shouldTruncateDescription], + ); + + const dateRange = useMemo(() => { + if (!temporalExtent) return ""; + + const startDate = convertDateToUTCString(temporalExtent[0]); + + let endDate: string = ""; + + // Check if end date exists and compare with start date + if ( + temporalExtent[1] && + temporalExtent[1].getTime() > temporalExtent[0].getTime() + ) { + endDate = convertDateToUTCString(temporalExtent[1]); + } + + return `${startDate}${endDate ? ` - ${endDate}` : ""}`; + }, [temporalExtent]); + + const renderDefaultDescription = () => ( +

+ {description}{" "} + {initialDescription.length > maxDescriptionLength && ( + + )} +

+ ); + + return ( +
+
+

{title ?? `ID: ${id}`}

+ {renderDescription + ? renderDescription(initialDescription) + : renderDefaultDescription()} +

{dateRange}

+
+
+
{indicatorTag && }
+
+ +
+
+
+ ); +}; diff --git a/src/components/composite/Catalog/CatalogList.module.css b/src/components/composite/Catalog/CatalogList.module.css new file mode 100644 index 0000000..1ecee78 --- /dev/null +++ b/src/components/composite/Catalog/CatalogList.module.css @@ -0,0 +1,41 @@ +.catalogContainer, +.catalogList { + display: flex; + flex-direction: column; +} + +.catalogList { + gap: 24px; +} + +.catalogContainer { + height: 100%; + overflow-y: auto; + gap: 16px; +} + +.catalogHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.catalogHeader h2 { + font-size: 28; + font-weight: 700; +} + +.catalogHeader span { + padding: 4px; + background: #000; + border-radius: 50%; + display: inline-flex; + justify-content: center; + align-items: center; + color: #fff; + font-size: 10px; + white-space: nowrap; + aspect-ratio: 1/1; + flex-shrink: 0; + min-width: 16px; +} diff --git a/src/components/composite/Catalog/CatalogList.test.tsx b/src/components/composite/Catalog/CatalogList.test.tsx new file mode 100644 index 0000000..35ddecc --- /dev/null +++ b/src/components/composite/Catalog/CatalogList.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { BaseCatalog } from "./types"; +import { CatalogList } from "./CatalogList"; + +const shortDescription = "Lorem ipsum dolor sit amet"; +const longDescription = + "Lorem ipsum dolor sit amet consectetur adipisicing elit. Minus in quam quod amet eaque rem, sit ex consequatur delectus porro a aliquid neque aliquam illum odit fuga libero suscipit quisquam."; + +const shortCatalog: BaseCatalog = { + id: "1", + title: "Catalog 1", + description: shortDescription, + temporalExtent: [new Date(2024, 1, 1), new Date(2025, 1, 1)], + indicatorTag: "API", +}; + +const longCatalog: BaseCatalog = { + id: "2", + title: "Catalog 2", + description: longDescription, + temporalExtent: [new Date(2024, 1, 1), new Date(2025, 1, 1)], +}; + +const catalogs = [shortCatalog, longCatalog]; + +describe("CatalogList", () => { + it("renders catalog list", async () => { + render(); + expect(screen.getByText("Catalogs")).toBeInTheDocument(); + expect(screen.getByTestId("catalogSize")).toHaveTextContent("2"); + expect(screen.getByText("Catalog 1")).toBeInTheDocument(); + expect(screen.getByText("Catalog 2")).toBeInTheDocument(); + }); + + it("renders empty list", async () => { + render(); + expect(screen.getByText("Catalogs")).toBeInTheDocument(); + expect(screen.getByTestId("catalogSize")).toHaveTextContent("0"); + expect(screen.getByText("No catalogs available.")).toBeInTheDocument(); + + render(); + expect(screen.getByText("No catalogs available.")).toBeInTheDocument(); + }); + + it("renders loading state", async () => { + render(); + expect(screen.getByText("Catalogs")).toBeInTheDocument(); + expect(screen.getByTestId("catalogSize")).toHaveTextContent("0"); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); +}); diff --git a/src/components/composite/Catalog/CatalogList.tsx b/src/components/composite/Catalog/CatalogList.tsx new file mode 100644 index 0000000..522a007 --- /dev/null +++ b/src/components/composite/Catalog/CatalogList.tsx @@ -0,0 +1,48 @@ +import styles from "./CatalogList.module.css"; +import { CatalogCard } from "./CatalogCard"; +import { BaseCatalog } from "./types"; + +export interface CatalogProps { + catalogs: BaseCatalog[] | null | undefined; + className?: string; + isLoading?: boolean; + maxDescriptionLength?: number; +} + +const Loading = () =>
Loading...
; + +const Empty = () =>
No catalogs available.
; + +export const CatalogList = ({ + catalogs, + isLoading, + className, + maxDescriptionLength, + ...props +}: CatalogProps) => ( +
+
+

Catalogs

+ + {Array.isArray(catalogs) && !isLoading ? catalogs.length : 0} + +
+ {isLoading ? ( + + ) : ( +
+ {Array.isArray(catalogs) ? ( + catalogs.map((catalog) => ( + + )) + ) : ( + + )} +
+ )} +
+); diff --git a/src/components/composite/Catalog/index.ts b/src/components/composite/Catalog/index.ts new file mode 100644 index 0000000..77cdd9c --- /dev/null +++ b/src/components/composite/Catalog/index.ts @@ -0,0 +1,3 @@ +export { CatalogCard } from "./CatalogCard"; +export { CatalogList } from "./CatalogList"; +export * from './types' diff --git a/src/components/composite/Catalog/types.ts b/src/components/composite/Catalog/types.ts new file mode 100644 index 0000000..930311a --- /dev/null +++ b/src/components/composite/Catalog/types.ts @@ -0,0 +1,11 @@ +export type TemporalExtent = [Date, Date?]; + +export type IndicatorTag = "API" | "Catalog"; + +export interface BaseCatalog { + id: string; + title?: string; + description: string; + temporalExtent?: TemporalExtent; + indicatorTag?: IndicatorTag; +} diff --git a/src/stories/CatalogCard.stories.tsx b/src/stories/CatalogCard.stories.tsx new file mode 100644 index 0000000..6329ead --- /dev/null +++ b/src/stories/CatalogCard.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CatalogCard } from "../components/composite/Catalog"; + +const meta = { + title: "Core/CatalogCard", + component: CatalogCard, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const shortDescription = "Lorem ipsum dolor sit amet"; +const longDescription = + "The Kentucky From Above ([KyFromAbove](https://kyfromabove.ky.gov)) program has acquired aerial imagery and elevation (LiDAR) data for the Commonwealth of Kentucky since 2011. The catalog will be subdivided into separate catalogs for orthorectified imagery, oblique imagery, LiDAR-derived digital elevation models (DEM), and LiDAR point cloud data. The data acquired through KyFromAbove program is free to download and consume. All imagery within the catalog uses the Cloud-Optimized Geotiff (COG) format. With the exception of the Phase1 point cloud LAZ files, all LAZ files use the Cloud-Optimized Point Cloud (COPC) format.\n\nSee also:\n\n- [STAC Browser version](https://radiantearth.github.io/stac-browser/#/external/kyfromabove-stac.s3.us-west-1.amazonaws.com/catalog.json)"; + +export const Default: Story = { + args: { + id: "1", + title: "This is a title", + description: longDescription, + temporalExtent: [ + new Date(2024, 1, 1), + new Date(2025, 1, 1), + ], + indicatorTag: "API", + }, +}; + +export const MissingDate: Story = { + args: { + id: "1", + title: "This is a title", + description: shortDescription, + temporalExtent: [new Date(2024, 1, 1)], + indicatorTag: "Catalog", + }, +}; + +export const NoDate: Story = { + args: { + id: "1", + title: "This is a title", + description: shortDescription, + indicatorTag: "Catalog", + }, +}; + +export const NoTitle: Story = { + args: { + id: "1", + description: shortDescription, + temporalExtent: [new Date(2024, 1, 1)], + indicatorTag: "Catalog", + }, +} + +export const CustomDescriptionRender: Story = { + args: { + id: "1", + title: "This is a title", + description: longDescription, + temporalExtent: [new Date(2024, 1, 1)], + indicatorTag: "Catalog", + renderDescription: (description) => ( +

{description}

+ ), + }, +}; diff --git a/src/stories/CatalogList.stories.tsx b/src/stories/CatalogList.stories.tsx new file mode 100644 index 0000000..7f2450b --- /dev/null +++ b/src/stories/CatalogList.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CatalogList, type BaseCatalog } from "../components/composite/Catalog"; + +const meta = { + title: "Composite/CatalogList", + component: CatalogList, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const longDescription = + "The Kentucky From Above ([KyFromAbove](https://kyfromabove.ky.gov)) program has acquired aerial imagery and elevation (LiDAR) data for the Commonwealth of Kentucky since 2011. The catalog will be subdivided into separate catalogs for orthorectified imagery, oblique imagery, LiDAR-derived digital elevation models (DEM), and LiDAR point cloud data. The data acquired through KyFromAbove program is free to download and consume. All imagery within the catalog uses the Cloud-Optimized Geotiff (COG) format. With the exception of the Phase1 point cloud LAZ files, all LAZ files use the Cloud-Optimized Point Cloud (COPC) format.\n\nSee also:\n\n- [STAC Browser version](https://radiantearth.github.io/stac-browser/#/external/kyfromabove-stac.s3.us-west-1.amazonaws.com/catalog.json)"; + +const catalogs: BaseCatalog[] = [ + { + id: "1", + title: "Catalog 1", + description: longDescription, + temporalExtent: [new Date(2024, 1, 1, 12, 22, 11)], + indicatorTag: "API", + }, + { + id: "2", + title: "Catalog 2", + description: "Catalog 2 Description", + temporalExtent: [new Date(2024, 1, 1), new Date(2024, 1, 1)], + indicatorTag: "API", + }, + { + id: "3", + title: "Catalog 3", + description: "Catalog 3 Description", + temporalExtent: [new Date(2024, 1, 1), new Date()], + indicatorTag: "API", + }, +]; + +export const Default: Story = { + args: { + catalogs, + }, +}; + +export const EmptyList: Story = { + args: { + catalogs: null, + }, +}; diff --git a/src/utils/exampleFunction.js b/src/utils/exampleFunction.js deleted file mode 100644 index cf7bc5e..0000000 --- a/src/utils/exampleFunction.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Example function that always returns true. - * - * This function demonstrates a simple arrow function - * that returns a boolean value. - * - * @description - * A brief description of what the function does. - * - * @returns {boolean} - * Always returns true. - * - * @example - * import { exampleFunction } from "../../utils"; - * - * const result = exampleFunction(); - * console.log(result); // Output: true - */ -export const exampleFunction = () => true \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js deleted file mode 100644 index b9d0b7b..0000000 --- a/src/utils/index.js +++ /dev/null @@ -1 +0,0 @@ -export { exampleFunction } from './exampleFunction'; \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..cf29797 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './strings'; \ No newline at end of file diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000..0a36df1 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,13 @@ +export const truncateText = (text: string, maxLength: number) => { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}...`; +}; + +export const convertDateToUTCString = (date: Date) => { + const formattedDate = + `${date.getUTCMonth() + 1}/${date.getUTCDate()}/${date.getUTCFullYear()}, ` + + `${date.getUTCHours() % 12 || 12}:${date.getUTCMinutes().toString().padStart(2, "0")}:${date.getUTCSeconds().toString().padStart(2, "0")} ` + + `${date.getUTCHours() < 12 ? "AM" : "PM"} UTC`; + + return formattedDate; +};