Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/components/composite/Catalog.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.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 {
width: 16px;
height: 16px;
background: #000;
border-radius: 100%;
display: inline-flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 8px;
}
20 changes: 20 additions & 0 deletions src/components/composite/Catalog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import styles from "./Catalog.module.css";
import { type BaseCatalog, CatalogCard } from "../core/CatalogCard";

export interface CatalogProps {
catalogs: BaseCatalog[];
}

export const Catalog = ({ catalogs, ...props }: CatalogProps) => (
<div className={styles.catalogContainer} {...props}>
<div className={styles.catalogHeader}>
<h2>Catalogs</h2>
<span>{catalogs.length}</span>
</div>
<div className={styles.catalogList}>
{catalogs.map((catalog) => (
<CatalogCard {...catalog} key={catalog.id} />
))}
</div>
</div>
);
69 changes: 69 additions & 0 deletions src/components/core/CatalogCard.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
.cardContainer * {
padding: 0;
margin: 0;
}

.cardContainer {
display: flex;
flex-direction: column;
border-radius: 8px;
padding: 12px;
border: 1px solid #00000027;
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);
}

.readMore {
border: none;
text-decoration: underline;
cursor: pointer;
background: none;
}
112 changes: 112 additions & 0 deletions src/components/core/CatalogCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useMemo, useState, JSX } from "react";
import { Button, Link } from "react-aria-components";

import type { DateValue } from "react-aria";
import styles from "./CatalogCard.module.css";

export type TemporalExtent = [DateValue, DateValue?];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we be more specific about DateValue null/undefined cases?
Maybe not, but I'm interested to know what happens if the 1st DateValue in the array is nullish.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we expect the library user to pass in one or two DateValue for TemporalExtent

So if user passes in null for the first item or anything that doesn't satisfy the type, the editor is gonna show an error. I think type checking with typescript is good enough. Wdyt?

Copy link
Collaborator

@JBurkinshaw JBurkinshaw Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I am happy with this approach in that case

Copy link
Collaborator

@JBurkinshaw JBurkinshaw Jan 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been putting some more thought into this as I modify the DatePicker component.
I'm not convinced that passing in props which use react-aria types like DateValue is ideal. It introduces another dependency and requires the developer to understand the types in the library.
There are a few alternatives, but I'm leaning to passing in regular JS Date objects and converting them to a react-aria type like DateValue within the component. What do you think?

As an example, here's what I'm working on for the DatePicker: #66

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me


export type IndicatorTag = "API" | "Catalog";

export interface BaseCatalog {
// eslint-disable-next-line react/no-unused-prop-types
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you think of a way to use id in here?
If not, would it make sense to make it optional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is used in the Catalog component when rendering each Catalog card (set key), so we still expect the data to have id property. It's showing a warning because the file we're in doesn't use id. For the interfaces that are used in multiple places, should we move it to somewhere else?

I would love to do something like this since CatalogList (rename later) and CatalogCard are so closely related, but not sitting well with our structure.

  • components
    • Catalog (directory)
      • CatalogList.tsx
      • CatalogCard.tsx
      • ... others tsx
      • 1 file for typescript types if needed
      • css module
      • test
      • index.ts to export reusable components

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that structure makes sense here and allows the interface to be in a different file too. The current flat directory structure is no very scaleable so moving to something more like this makes more sense to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have done this change in my local. Should I push it now or should we discuss more about it, as it's gonna change the way we structure drastically? If doing this, we won't differentiate core / composite components anymore in the codebase, but still have component types on storybook.

id: string;
title: string;
description: string;
temporalExtent: TemporalExtent;
indicatorTag?: IndicatorTag;
}

export interface CatalogCardProps extends BaseCatalog {
renderDescription?: (description: string) => JSX.Element;
}

const MAX_LENGTH = 250;

const Tag = ({ indicatorTag }: { indicatorTag: IndicatorTag }) => (
<div
className={`${styles.tag} ${
indicatorTag === "API" ? styles.tagAPI : styles.tagCatalog
}`}
>
{indicatorTag}
</div>
);

export const CatalogCard = ({
title,
description: initialDescription,
temporalExtent,
indicatorTag,
renderDescription,
}: CatalogCardProps) => {
const [shouldTruncateDescription, setShouldTruncateDescription] =
useState(true);

const isLongText = initialDescription.length > MAX_LENGTH;

const description = useMemo(
() =>
shouldTruncateDescription
? `${initialDescription.slice(0, MAX_LENGTH)}${isLongText ? "..." : ""}`
: initialDescription,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving this to a utility function. Something like this:

const truncateDescription = (text: string, maxLength: number) => {
    if (text.length <= maxLength) return text;
    return `${text.slice(0, maxLength)}...`;
};

There's actually a utils directory. The aim is to abstract useful utilities so we can use them without components in the future if required.

[initialDescription, isLongText, shouldTruncateDescription],
);

const dateRange = useMemo(() => {
const startDate = temporalExtent[0].toString();

let endDate: string = "";

// Check if end date exists and compare with start date
if (
temporalExtent[1] &&
temporalExtent[1].compare(temporalExtent[0]) !== 0
) {
endDate = temporalExtent[1].toString();
}

return `${startDate}${endDate ? ` - ${endDate}` : ""}`;
}, [temporalExtent]);

const renderDefaultDescription = () => (
<p className={styles.description}>
{description}{" "}
{isLongText && (
<Button
className={styles.readMore}
onPress={() =>
setShouldTruncateDescription(!shouldTruncateDescription)
}
aria-expanded={!shouldTruncateDescription}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also add an aria-label property here for accessibility

>
{shouldTruncateDescription ? "Read More" : "Read Less"}
</Button>
)}
</p>
);

return (
<div className={styles.cardContainer}>
<div className={styles.infoContainer}>
<h3 className={styles.title}>{title}</h3>
{renderDescription
? renderDescription(initialDescription)
: renderDefaultDescription()}
<p className={styles.date}>{dateRange}</p>
</div>
<div className={styles.footerContainer}>
<div>{indicatorTag && <Tag indicatorTag={indicatorTag} />}</div>
<div>
<Link
href="#todo"
target="_blank"
className={styles.browseButton}
>
Browse
</Link>
</div>
</div>
</div>
);
};
55 changes: 55 additions & 0 deletions src/stories/Catalog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react";

import { CalendarDateTime } from "@internationalized/date";
import { Catalog } from "../components/composite/Catalog";
import { BaseCatalog } from "../components/core/CatalogCard";

const meta = {
title: "Composite/Catalog",
component: Catalog,
} satisfies Meta<typeof Catalog>;

export default meta;

type Story = StoryObj<typeof meta>;

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 CalendarDateTime(2024, 1, 1, 12, 22, 11),
],
indicatorTag: "API",
},
{
id: "2",
title: "Catalog 2",
description: "Catalog 2 Description",
temporalExtent: [
new CalendarDateTime(2024, 1, 1),
new CalendarDateTime(2024, 1, 1),
],
indicatorTag: "API",
},
{
id: "3",
title: "Catalog 3",
description: "Catalog 3 Description",
temporalExtent: [
new CalendarDateTime(2024, 1, 1),
new CalendarDateTime(2025, 1, 1),
],
indicatorTag: "API",
},
];

export const Default: Story = {
args: {
catalogs,
},
};
50 changes: 50 additions & 0 deletions src/stories/CatalogCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react";

import { CalendarDateTime } from "@internationalized/date";
import { CatalogCard } from "../components/core/CatalogCard";

const meta = {
title: "Core/CatalogCard",
component: CatalogCard,
} satisfies Meta<typeof CatalogCard>;

export default meta;

type Story = StoryObj<typeof meta>;

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: {
title: "This is a title",
description: longDescription,
temporalExtent: [
new CalendarDateTime(2024, 1, 1),
new CalendarDateTime(2025, 1, 1),
],
indicatorTag: "API",
},
};

export const MissingDate: Story = {
args: {
title: "This is a title",
description: shortDescription,
temporalExtent: [new CalendarDateTime(2024, 1, 1)],
indicatorTag: "Catalog",
},
};

export const CustomDescriptionRender: Story = {
args: {
title: "This is a title",
description: longDescription,
temporalExtent: [new CalendarDateTime(2024, 1, 1)],
indicatorTag: "Catalog",
renderDescription: (description) => (
<p style={{ color: "red" }}>{description}</p>
),
},
};
Loading