diff --git a/apps/cns-website/public/assets/content/exhibit/data.yaml b/apps/cns-website/public/assets/content/exhibit/data.yaml new file mode 100644 index 0000000000..2b6a9f4374 --- /dev/null +++ b/apps/cns-website/public/assets/content/exhibit/data.yaml @@ -0,0 +1,141 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: 30-Year Exhibit +subtitle: | + Explore the *Places & Spaces* (2005-2024) and *Envisioning Intelligences* exhibits (2025-2034). +breadcrumbs: + - name: Home + route: / + - name: 30-Year Exhibit +headerContent: + - component: GridContainer + content: + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/envisioning-intelligences.png + tagline: Envisioning Intelligences (2025-2034) + content: + - component: Markdown + data: The third decade welcomes visualizations of linguistic, kinesthetic, emotional, and other intelligences with a focus on collaboration and coordination across life forms. + actionsLeft: + component: TextHyperlink + text: View visualizations + url: /exhibit/envisioning-intelligences + actionsRight: + component: TextHyperlink + text: Talks and demos + url: https://cns-iu.github.io/workshops/2025-24h + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/macroscopes.png + tagline: Macroscopes (2015-2024) + content: + - component: Markdown + data: Macroscopes refer to interactive data visualizations, allowing us to engage directly with large datasets in ways that empower discovery and direct our own lines of questioning. + actionsLeft: + component: TextHyperlink + text: View macroscopes + url: https://scimaps.org/macroscopes + actionsRight: + component: TextHyperlink + text: Talks and demos + url: https://cns-iu.github.io/workshops/2023-12-9_24hour_science_map/ + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/maps.png + tagline: Maps (2005-2014) + content: + - component: Markdown + data: For centuries, cartographic maps of earth and water have guided human exploration. Maps of science showcase the contexts, relationships, and dynamism of science. + actionsLeft: + component: TextHyperlink + text: View maps + url: https://scimaps.org/maps + actionsRight: + component: TextHyperlink + text: Talks and demos + url: https://cns-iu.github.io/workshops/2021-12-10_24hour_science_map +content: + - component: PageSection + tagline: About the exhibits + anchor: about-the-exhibits + level: 2 + content: + - component: Markdown + data: | + Since 2005, the *Places & Spaces* exhibit team has shared world-class examples of science maps and interactive + data visualizations via physical exhibits, 219 press items, and more than 8 million website visits. Together, + the different exhibit iterations aim to create ever more excitement for the power and value of science, share + the pleasures of scientific discovery, and give good energy to all on this planet. + - component: Button + label: Explore exhibit + href: https://scimaps.org + type: cta + + - component: PageSection + tagline: Venues and events + anchor: venues-and-events + level: 2 + content: + - component: Markdown + data: The exhibit has been on display at more that 480 venues in more than 160 cities. + - component: VenuesTable + venuesUrl: https://dev.scimaps.org/assets/indexes/venues.json + linkBaseHref: https://scimaps.org/ + + - component: PageSection + tagline: Talks and demos + anchor: talks-and-demos + level: 2 + content: + - component: Markdown + data: | + Over the course of 24 hours, scholars and practitioners from around the globe discussed their work, invited + viewers behind the scenes, and demonstrated valuable tools and methodologies. + + - component: GridContainer + content: + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/EI2.png + tagline: Envisioning Intelligences + content: + - component: Markdown + data: December 13-14, 2025 + actionsLeft: + component: TextHyperlink + text: Event website + url: https://cns-iu.github.io/workshops/2025-24h/ + actionsRight: + component: TextHyperlink + text: YouTube playlist + url: https://www.youtube.com/playlist?list=PL-CUnYVIy7DPbiDhpt0M233PeVV9WVyDv + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/atlas-of-inequality.png + tagline: Macroscopes + content: + - component: Markdown + data: December 9-10, 2023 + actionsLeft: + component: TextHyperlink + text: Event website + url: https://cns-iu.github.io/workshops/2023-12-9_24hour_science_map/ + actionsRight: + component: TextHyperlink + text: YouTube playlist + url: https://www.youtube.com/playlist?list=PL-CUnYVIy7DNM0EMoeWvIn-pHD2-QmPhx + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/2pm.png + tagline: Maps + content: + - component: Markdown + data: December 11-12, 2021 + actionsLeft: + component: TextHyperlink + text: Event website + url: https://cns-iu.github.io/workshops/2021-12-10_24hour_science_map/ + actionsRight: + component: TextHyperlink + text: YouTube playlist + url: https://www.youtube.com/playlist?list=PL-CUnYVIy7DO6TkNXWpHrxISnMf2Yny-f diff --git a/apps/cns-website/public/assets/content/exhibit/images/2pm.png b/apps/cns-website/public/assets/content/exhibit/images/2pm.png new file mode 100644 index 0000000000..abedbbf1d2 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/2pm.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/EI2.png b/apps/cns-website/public/assets/content/exhibit/images/EI2.png new file mode 100644 index 0000000000..b40d3e25fc Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/EI2.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/atlas-of-inequality.png b/apps/cns-website/public/assets/content/exhibit/images/atlas-of-inequality.png new file mode 100644 index 0000000000..24e3f965f2 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/atlas-of-inequality.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/envisioning-intelligences.png b/apps/cns-website/public/assets/content/exhibit/images/envisioning-intelligences.png new file mode 100644 index 0000000000..f9680f8149 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/envisioning-intelligences.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/macroscopes.png b/apps/cns-website/public/assets/content/exhibit/images/macroscopes.png new file mode 100644 index 0000000000..b6866ad374 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/macroscopes.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/maps.png b/apps/cns-website/public/assets/content/exhibit/images/maps.png new file mode 100644 index 0000000000..20c70cbe8d Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/maps.png differ diff --git a/apps/cns-website/src/app/app.config.ts b/apps/cns-website/src/app/app.config.ts index 05b4a4d4b8..e9bc73e01b 100644 --- a/apps/cns-website/src/app/app.config.ts +++ b/apps/cns-website/src/app/app.config.ts @@ -25,6 +25,7 @@ import { GridContainerDef } from '@hra-ui/design-system/content-templates/grid-c import { ImageDef } from '@hra-ui/design-system/content-templates/image'; import { MarkdownDef } from '@hra-ui/design-system/content-templates/markdown'; import { PageSectionDef } from '@hra-ui/design-system/content-templates/page-section'; +import { VenuesTableDef } from '@hra-ui/design-system/content-templates/venues-table'; import { YouTubePlayerDef } from '@hra-ui/design-system/content-templates/youtube-player'; import { IconDef } from '@hra-ui/design-system/icons'; import { PageTableDef } from '@hra-ui/design-system/table'; @@ -65,6 +66,7 @@ export const appConfig: ApplicationConfig = { ApiCommandDef, ButtonDef, FlexContainerDef, + GoogleMapsDef, GridContainerDef, IconDef, ImageDef, @@ -73,8 +75,8 @@ export const appConfig: ApplicationConfig = { PageTableDef, ProfileCardDef, TextHyperlinkDef, + VenuesTableDef, YouTubePlayerDef, - GoogleMapsDef, ]), provideDesignSystem(), provideMarkdown({ loader: HttpClient }), diff --git a/apps/cns-website/src/app/app.routes.ts b/apps/cns-website/src/app/app.routes.ts index a6f66a6d0a..755673aca6 100644 --- a/apps/cns-website/src/app/app.routes.ts +++ b/apps/cns-website/src/app/app.routes.ts @@ -83,6 +83,14 @@ export const appRoutes: Route[] = [ { path: 'exhibit', children: [ + { + path: '', + pathMatch: 'full', + component: ContentPageComponent, + resolve: { + data: createYamlSpecResolver('assets/content/exhibit/data.yaml', ContentPageDataSchema), + }, + }, { path: 'envisioning-intelligences', component: ContentPageComponent, diff --git a/apps/cns-website/src/app/components/content-page/content-page.component.ts b/apps/cns-website/src/app/components/content-page/content-page.component.ts index 3b52f00109..92e47bb80a 100644 --- a/apps/cns-website/src/app/components/content-page/content-page.component.ts +++ b/apps/cns-website/src/app/components/content-page/content-page.component.ts @@ -34,12 +34,12 @@ import { FooterComponent } from '../footer/footer.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ContentPageComponent { - /** input data for content page */ + /** Input data for content page */ readonly data = input.required(); - /** header content data */ + /** Header content data */ protected readonly headerContent = computed(() => coerceArray(this.data().headerContent ?? [])); - /** content data */ + /** Content data */ protected readonly content = computed(() => coerceArray(this.data().content)); } diff --git a/apps/cns-website/src/app/components/header/header.component.scss b/apps/cns-website/src/app/components/header/header.component.scss index 9dfff51ec6..55d9126d2e 100644 --- a/apps/cns-website/src/app/components/header/header.component.scss +++ b/apps/cns-website/src/app/components/header/header.component.scss @@ -27,6 +27,12 @@ $z-index: 900; } .header { + @include mat.icon-button-overrides( + ( + state-layer-size: 3rem, + ) + ); + position: relative; z-index: $z-index + 2; } @@ -44,19 +50,18 @@ $z-index: 900; .logo { display: flex; - padding: 0 0.5rem; + width: 5.75rem; + justify-content: center; } .menu-button { - height: 2.5rem; - @include mat.button-overrides( ( text-label-text-font: vars.$label-large-font, text-label-text-size: vars.$label-large-size, text-label-text-weight: vars.$label-large-weight, text-label-text-tracking: vars.$label-large-tracking, - text-horizontal-padding: 0.75rem, + text-horizontal-padding: 0.625rem, ) ); @@ -70,7 +75,10 @@ $z-index: 900; ); ::ng-deep .mat-button-toggle-label-content { - padding: 0 0.75rem !important; + display: flex; + height: 2.5rem; + align-items: center; + padding: 0 0.625rem !important; } } } diff --git a/apps/cns-website/src/app/components/header/static-data/menus.json b/apps/cns-website/src/app/components/header/static-data/menus.json index 580e09f158..495083d2ee 100644 --- a/apps/cns-website/src/app/components/header/static-data/menus.json +++ b/apps/cns-website/src/app/components/header/static-data/menus.json @@ -60,7 +60,6 @@ }, { "type": "item", - "id": "publications", "label": "Publications", "url": "https://cns.iu.edu/research/?category=publication&view=list&group-by=year" }, @@ -141,11 +140,15 @@ }, { "type": "item", - "id": "teaching", "label": "Teaching", "url": "https://ivmooc.cns.iu.edu/", "external": true, "target": "_blank" + }, + { + "type": "item", + "label": "Exhibits", + "url": "https://cns.iu.edu/exhibit" } ] } diff --git a/apps/cns-website/src/app/schemas/content-page/content-page.schema.json b/apps/cns-website/src/app/schemas/content-page/content-page.schema.json index ca0aeedc1f..a1561a5847 100644 --- a/apps/cns-website/src/app/schemas/content-page/content-page.schema.json +++ b/apps/cns-website/src/app/schemas/content-page/content-page.schema.json @@ -34,10 +34,7 @@ "type": "string" } }, - "required": [ - "label", - "url" - ], + "required": ["label", "url"], "additionalProperties": false }, "headerContent": { @@ -47,12 +44,7 @@ "$ref": "#/$defs/ProjectedContentTemplate" } }, - "required": [ - "$schema", - "title", - "subtitle", - "content" - ], + "required": ["$schema", "title", "subtitle", "content"], "additionalProperties": false }, "IconList": { @@ -113,9 +105,7 @@ "type": "string" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false }, "ProjectedContentTemplate": { @@ -224,11 +214,7 @@ "$ref": "#/$defs/ProjectedContentTemplate" } }, - "required": [ - "component", - "variant", - "tagline" - ], + "required": ["component", "variant", "tagline"], "additionalProperties": false }, "Classes": { @@ -275,20 +261,13 @@ "type": "string" } }, - "required": [ - "id" - ], + "required": ["id"], "additionalProperties": {} }, "ActionCardVariant": { "id": "ActionCardVariant", "type": "string", - "enum": [ - "elevated", - "flat", - "outlined", - "outlined-with-icons" - ] + "enum": ["elevated", "flat", "outlined", "outlined-with-icons"] }, "ApiCommand": { "id": "ApiCommand", @@ -315,21 +294,13 @@ }, "method": { "type": "string", - "enum": [ - "GET", - "POST" - ] + "enum": ["GET", "POST"] }, "rightButton": { "$ref": "#/$defs/ApiCommandButton" } }, - "required": [ - "component", - "request", - "method", - "rightButton" - ], + "required": ["component", "request", "method", "rightButton"], "additionalProperties": false }, "ApiCommandButton": { @@ -347,9 +318,7 @@ "format": "uri" } }, - "required": [ - "label" - ], + "required": ["label"], "additionalProperties": false }, "Button": { @@ -380,26 +349,15 @@ }, "type": { "type": "string", - "enum": [ - "default", - "flat", - "cta", - "fab" - ] + "enum": ["default", "flat", "cta", "fab"] }, "variant": { "type": "string", - "enum": [ - "primary", - "secondary" - ] + "enum": ["primary", "secondary"] }, "size": { "type": "string", - "enum": [ - "small", - "medium" - ] + "enum": ["small", "medium"] }, "disabled": { "type": "boolean" @@ -408,11 +366,7 @@ "type": "string" } }, - "required": [ - "component", - "label", - "href" - ], + "required": ["component", "label", "href"], "additionalProperties": false }, "FlexContainer": { @@ -448,10 +402,7 @@ } } }, - "required": [ - "component", - "content" - ], + "required": ["component", "content"], "additionalProperties": false }, "GoogleMaps": { @@ -484,12 +435,7 @@ "type": "string" } }, - "required": [ - "component", - "url", - "externalUrl", - "fallbackImageUrl" - ], + "required": ["component", "url", "externalUrl", "fallbackImageUrl"], "additionalProperties": false }, "GridContainer": { @@ -522,10 +468,7 @@ } } }, - "required": [ - "component", - "content" - ], + "required": ["component", "content"], "additionalProperties": false }, "Icon": { @@ -561,9 +504,7 @@ "type": "boolean" } }, - "required": [ - "component" - ], + "required": ["component"], "additionalProperties": false }, "Image": { @@ -593,10 +534,7 @@ "type": "string" } }, - "required": [ - "component", - "src" - ], + "required": ["component", "src"], "additionalProperties": false }, "Markdown": { @@ -626,9 +564,7 @@ "type": "string" } }, - "required": [ - "component" - ], + "required": ["component"], "additionalProperties": false }, "PageSection": { @@ -675,11 +611,7 @@ "$ref": "#/$defs/ProjectedContentTemplate" } }, - "required": [ - "component", - "tagline", - "content" - ], + "required": ["component", "tagline", "content"], "additionalProperties": false }, "Table": { @@ -730,9 +662,7 @@ "type": "boolean" } }, - "required": [ - "component" - ], + "required": ["component"], "additionalProperties": false }, "TableColumn": { @@ -797,11 +727,7 @@ ] } }, - "required": [ - "column", - "label", - "type" - ], + "required": ["column", "label", "type"], "additionalProperties": false }, "TextColumnType": { @@ -813,9 +739,7 @@ "const": "text" } }, - "required": [ - "type" - ], + "required": ["type"], "additionalProperties": false }, "NumericColumnType": { @@ -830,9 +754,7 @@ "type": "boolean" } }, - "required": [ - "type" - ], + "required": ["type"], "additionalProperties": false }, "MarkdownColumnType": { @@ -844,9 +766,7 @@ "const": "markdown" } }, - "required": [ - "type" - ], + "required": ["type"], "additionalProperties": false }, "LinkColumnType": { @@ -864,10 +784,7 @@ "type": "boolean" } }, - "required": [ - "type", - "urlColumn" - ], + "required": ["type", "urlColumn"], "additionalProperties": false }, "IconColumnType": { @@ -885,10 +802,7 @@ "type": "string" } }, - "required": [ - "type", - "icon" - ], + "required": ["type", "icon"], "additionalProperties": false }, "MenuButtonColumnType": { @@ -909,11 +823,7 @@ "type": "string" } }, - "required": [ - "type", - "icon", - "options" - ], + "required": ["type", "icon", "options"], "additionalProperties": false }, "TableRow": { @@ -943,11 +853,7 @@ "TableVariant": { "id": "TableVariant", "type": "string", - "enum": [ - "alternating", - "divider", - "basic" - ] + "enum": ["alternating", "divider", "basic"] }, "ProfileCard": { "id": "ProfileCard", @@ -985,12 +891,7 @@ "$ref": "#/$defs/ProjectedContentTemplate" } }, - "required": [ - "component", - "name", - "description", - "pictureUrl" - ], + "required": ["component", "name", "description", "pictureUrl"], "additionalProperties": false }, "TextHyperlink": { @@ -1026,11 +927,7 @@ "type": "boolean" } }, - "required": [ - "component", - "text", - "url" - ], + "required": ["component", "text", "url"], "additionalProperties": false }, "YouTubePlayer": { @@ -1060,12 +957,8 @@ "type": "string" } }, - "required": [ - "component", - "videoId", - "label" - ], + "required": ["component", "videoId", "label"], "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts b/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts index 07820fcc08..76dc1cf3a2 100644 --- a/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts +++ b/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts @@ -11,6 +11,7 @@ import { GridContainerSchema } from '@hra-ui/design-system/content-templates/gri import { ImageSchema } from '@hra-ui/design-system/content-templates/image'; import { MarkdownSchema } from '@hra-ui/design-system/content-templates/markdown'; import { PageSectionSchema } from '@hra-ui/design-system/content-templates/page-section'; +import { VenuesTableSchema } from '@hra-ui/design-system/content-templates/venues-table'; import { YouTubePlayerSchema } from '@hra-ui/design-system/content-templates/youtube-player'; import { IconSchema } from '@hra-ui/design-system/icons'; import { PageTableSchema } from '@hra-ui/design-system/table'; @@ -31,6 +32,7 @@ export default z.lazy(() => { PageTableSchema, ProfileCardSchema, TextHyperlinkSchema, + VenuesTableSchema, YouTubePlayerSchema, ]); diff --git a/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts b/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts index bbfa92e041..e9537fb5c0 100644 --- a/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts +++ b/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts @@ -89,6 +89,10 @@ export const Flat: Story = { }; export const Outlined: Story = { + args: { + subtagline: 'Small f', + }, + render: render( 'outlined', ` diff --git a/libs/design-system/content-templates/venues-table/ng-package.json b/libs/design-system/content-templates/venues-table/ng-package.json new file mode 100644 index 0000000000..c781f0df46 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/design-system/content-templates/venues-table/src/index.ts b/libs/design-system/content-templates/venues-table/src/index.ts new file mode 100644 index 0000000000..94523c1540 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/venues-table.component'; +export { VenuesTableDef } from './lib/types/venues-table.definition'; +export { VenuesTable, VenueDataSchema, VenuesTableSchema } from './lib/types/venues-table.schema'; diff --git a/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.definition.ts b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.definition.ts new file mode 100644 index 0000000000..a206d13bf9 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.definition.ts @@ -0,0 +1,9 @@ +import { ContentTemplateDef } from '@hra-ui/cdk/content-template'; +import { VenuesTableComponent } from '../venues-table.component'; +import { VenuesTableSchema } from './venues-table.schema'; + +/** Content template definition for VenuesTableComponent */ +export const VenuesTableDef: ContentTemplateDef = { + component: VenuesTableComponent, + spec: VenuesTableSchema, +}; diff --git a/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.schema.ts b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.schema.ts new file mode 100644 index 0000000000..454a29b49d --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.schema.ts @@ -0,0 +1,42 @@ +import { ContentTemplateSchema } from '@hra-ui/cdk/content-template'; +import { PageTableSchema } from '@hra-ui/design-system/table'; +import * as z from 'zod'; + +/** Type for a single venue item */ +export type VenueItem = z.infer; + +/** Venue item schema */ +export const VenueItemSchema = z + .object({ + dateStart: z.coerce.date(), + dateEnd: z.coerce.date().optional(), + title: z.string(), + venue: z.string().nullish(), + organizer: z.string().nullish(), + credit: z.string().nullish(), + city: z.string().nullish(), + state: z.string().nullish(), + country: z.string().nullish(), + pdfLink: z.string().nullish(), + websiteUrl: z.string().optional(), + venueImages: z.array(z.object({ sm: z.string().optional(), lg: z.string().optional() })).nullish(), + }) + .meta({ id: 'VenueItem' }); + +/** Type for the Venue data array */ +export type VenueData = z.infer; + +/** Venue data schema (array of items) */ +export const VenueDataSchema = z.array(VenueItemSchema); + +/** Type for Venues Table */ +export type VenuesTable = z.infer; + +/** Schema for Venues Table */ +export const VenuesTableSchema = ContentTemplateSchema.merge(PageTableSchema) + .extend({ + component: z.literal('VenuesTable'), + venuesUrl: z.string(), + linkBaseHref: z.string().optional(), + }) + .meta({ id: 'VenuesTable' }); diff --git a/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.html b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.html new file mode 100644 index 0000000000..094d3eb834 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.html @@ -0,0 +1,8 @@ + diff --git a/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.scss b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.scss new file mode 100644 index 0000000000..2b741d7afc --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.scss @@ -0,0 +1,60 @@ +@use '@angular/material' as mat; +@use '../../../../styles/vars'; + +:host { + ::ng-deep hra-table { + // TODO: remove when colors are updated + --mat-sys-outline-variant: #bbc4b5; + --mat-sys-surface-container-highest: #e0e3dc; + --mat-sys-surface-bright: #f1f3ee; + --mat-sys-on-surface: #171e19; + --mat-sys-on-surface-variant: #3c463a; + --mat-sys-outline: #869181; + + --hra-table-max-height: 26.125rem; + border-color: vars.$outline; + border-radius: 0.5rem; + + @include mat.table-overrides( + ( + header-headline-color: vars.$on-surface, + row-item-label-text-color: vars.$on-surface-variant, + ) + ); + + thead { + tr { + background-color: vars.$surface-container-highest; + } + } + + .mat-column-date { + min-width: 8rem; + max-width: 9.75rem; + } + + .mat-column-event { + min-width: 10rem; + max-width: 30.25rem; + } + + .mat-column-location { + min-width: 8.75rem; + max-width: 11.25rem; + } + + .mat-column-contact { + min-width: 10rem; + max-width: 13.5rem; + } + + .mat-column-links { + min-width: 8rem; + max-width: 11.25rem; + + a { + color: inherit; + } + } + } +} diff --git a/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.ts b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.ts new file mode 100644 index 0000000000..47ff48bb84 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.ts @@ -0,0 +1,138 @@ +import { HttpClient, httpResource } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { assetUrl } from '@hra-ui/common/url'; +import { TableColumn, TableComponent, TableRow } from '@hra-ui/design-system/table'; +import { VenueData, VenueDataSchema, VenueItem } from './types/venues-table.schema'; + +/** + * Component to display a table of venues for Scimaps exhibit + */ +@Component({ + selector: 'hra-venues-table', + imports: [TableComponent], + templateUrl: './venues-table.component.html', + styleUrl: './venues-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VenuesTableComponent { + /** HttpClient for making API requests */ + readonly http = inject(HttpClient); + + /** URL to fetch venues data from */ + readonly venuesUrl = input.required(); + + /** Base href for links in the table (e.g. website, photo gallery, PDF) */ + readonly linkBaseHref = input(); + + /** Columns for the venues table */ + protected readonly columns: TableColumn[] = [ + { + column: 'date', + label: 'Date', + type: 'date', + }, + { + column: 'event', + label: 'Event', + type: 'markdown', + }, + { + column: 'location', + label: 'Location', + type: 'text', + }, + { + column: 'contact', + label: 'Contact', + type: 'text', + }, + { + column: 'links', + label: 'Links', + type: 'markdown', + }, + ]; + + /** Resource to fetch venues data */ + protected readonly venuesData = httpResource(assetUrl(this.venuesUrl), { + parse: (data) => VenueDataSchema.parse(data), + defaultValue: [], + }); + + /** Table rows computed from the venues data */ + protected readonly rows = computed(() => this.convertToTableRows(this.venuesData.value())); + + /** + * Converts venues data to table rows + * @param venues Venues data to convert + * @returns Table rows generated from the venues data + */ + private convertToTableRows(venues: VenueData): TableRow[] { + return venues.map((venue) => ({ + date: venue.dateStart, + event: venue.title.replace(/Places\s*&\s*Spaces/g, '*Places & Spaces*'), + location: [venue.city, venue.state, venue.country].filter((s) => !!s).join(', '), + contact: venue.organizer || '', + links: this.createLinks(venue), + })); + } + + /** + * Formats a date as a segmented string (YYYY/MM-DD) for use in URLs + * @param date Date to format + * @returns Formatted date string in the format YYYY/MM-DD + */ + private getSegmentedDate(date: Date): string { + const year = date.getUTCFullYear(); + const day = String(date.getUTCDate()).padStart(2, '0'); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${year}/${month}-${day}`; + } + + /** + * Creates markdown links for the venue based on available data (website, photo gallery, PDF) + * @param venue Venue item to create links for + * @returns Markdown string containing links for the venue (website, photo gallery, PDF) based on available data + */ + private createLinks(venue: VenueItem): string { + const links = []; + if (venue.websiteUrl) { + links.push(`[Website](${venue.websiteUrl})`); + } + if (venue.venueImages) { + links.push(`[Photo gallery](${this.buildLinkUrl('venues/gallery', venue.dateStart, venue.title, '')})`); + } + if (venue.pdfLink) { + links.push(`[PDF](${this.buildLinkUrl('assets/content/venues', venue.dateStart, venue.title, venue.pdfLink)})`); + } + return links.join(' | '); + } + + private slugifyTitle(title: string): string { + return title + .toLowerCase() + .trim() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/&/g, 'and') // Replace '&' with 'and' + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Strip diacritics after transliteration + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/--+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text + } + + /** + * Builds a link URL for the venue based on the provided path, date, title, and optional extra segment + * @param path Base path for the link (e.g. 'venues/gallery' or 'assets/content/venues') + * @param date Date of the venue, used to create a segmented date string for the URL + * @param title Title of the venue, used to create a slugified segment for the URL + * @param [extra] Optional extra segment to append to the URL (e.g. PDF filename) + * @returns Constructed URL string combining the base href, path, segmented date, slugified title, and optional extra segment + */ + private buildLinkUrl(path: string, date: Date, title: string, extra?: string): string { + const dateSegment = this.getSegmentedDate(date); + const titleSegment = this.slugifyTitle(title); + return [this.linkBaseHref(), path, dateSegment, titleSegment, extra].filter((s) => !!s).join('/'); + } +} diff --git a/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts b/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts index 34c0863d8a..8de85f7858 100644 --- a/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts +++ b/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts @@ -38,6 +38,7 @@ export type VersionedDataTable = z.infer; export const VersionedDataTableSchema = ContentTemplateSchema.merge( PageTableSchema.pick({ columns: true, + rows: true, variant: true, enableSort: true, verticalDividers: true, diff --git a/libs/design-system/table/src/lib/table/table.component.html b/libs/design-system/table/src/lib/table/table.component.html index d274b88419..b8d23c54ae 100644 --- a/libs/design-system/table/src/lib/table/table.component.html +++ b/libs/design-system/table/src/lib/table/table.component.html @@ -15,11 +15,13 @@ mat-table matSort aria-label="Table with sort function" + [matSortActive]="initialSort()?.active || ''" + [matSortDirection]="initialSort()?.direction || ''" [dataSource]="dataSource" [matSortDisabled]="!enableSort()" [class.vertical-divider]="verticalDividers()" > - @let templates = { text, numeric, markdown, link, icon, menu, dataExploration }; + @let templates = { text, date, numeric, markdown, link, icon, menu, dataExploration }; @if (enableRowSelection()) { @@ -111,6 +113,10 @@ {{ text }} + + {{ date | date }} + + {{ value | number }} diff --git a/libs/design-system/table/src/lib/table/table.component.spec.ts b/libs/design-system/table/src/lib/table/table.component.spec.ts index 19b497c0be..9c7e5ca028 100644 --- a/libs/design-system/table/src/lib/table/table.component.spec.ts +++ b/libs/design-system/table/src/lib/table/table.component.spec.ts @@ -2,14 +2,22 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { ErrorHandler, EnvironmentProviders, Provider } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { render, screen } from '@testing-library/angular'; import { provideMarkdown } from 'ngx-markdown'; import { unparse } from 'papaparse'; import userEvent from '@testing-library/user-event'; +import saveAs from 'file-saver'; import { TableColumn, TableRow } from '../types/page-table.schema'; import { TableComponent } from './table.component'; +jest.mock('file-saver', () => jest.fn()); + describe('TableComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const TABLE_COLUMNS: TableColumn[] = [ { column: 'serial_no', @@ -135,4 +143,153 @@ describe('TableComponent', () => { expect(screen.getByText('Bob')).toBeInTheDocument(); expect(screen.getByText('Charlie')).toBeInTheDocument(); }); + + it('should infer columns when columns input is not provided', async () => { + await setup({ + rows: [ + { label: 'Alpha', count: 2 }, + { label: 'Beta', count: 5 }, + ], + }); + + expect(screen.getByText('label')).toBeInTheDocument(); + expect(screen.getByText('count')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should toggle all rows from the header checkbox and emit selection', async () => { + const { fixture, user } = await setup({ + rows: TABLE_ROWS, + columns: TABLE_COLUMNS, + enableRowSelection: true, + }); + + const selectionChangeSpy = jest.fn(); + fixture.componentInstance.selectionChange.subscribe(selectionChangeSpy); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[0]); + + expect(selectionChangeSpy).toHaveBeenCalled(); + const latestSelection = selectionChangeSpy.mock.calls.at(-1)?.[0] as TableRow[]; + expect(latestSelection).toHaveLength(TABLE_ROWS.length); + }); + + it('should toggle a single row selection and emit selected row', async () => { + const { fixture, user } = await setup({ + rows: TABLE_ROWS, + columns: TABLE_COLUMNS, + enableRowSelection: true, + }); + + const selectionChangeSpy = jest.fn(); + fixture.componentInstance.selectionChange.subscribe(selectionChangeSpy); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + + expect(selectionChangeSpy).toHaveBeenCalled(); + const latestSelection = selectionChangeSpy.mock.calls.at(-1)?.[0] as TableRow[]; + expect(latestSelection).toEqual([TABLE_ROWS[0]]); + }); + + it('should emit routeClicked for internal links', async () => { + const routeColumns: TableColumn[] = [ + { + column: 'name', + label: 'Name', + type: { + type: 'link', + urlColumn: 'route', + internal: true, + }, + }, + ]; + const routeRows: TableRow[] = [{ name: 'Open Details', route: '/details/42' }]; + + const { fixture, user } = await setup({ rows: routeRows, columns: routeColumns }); + const routeClickSpy = jest.fn(); + fixture.componentInstance.routeClicked.subscribe(routeClickSpy); + + await user.click(screen.getByText('Open Details')); + + expect(routeClickSpy).toHaveBeenCalledWith('/details/42'); + }); + + it('should emit downloadHovered when menu button is hovered', async () => { + const menuColumns: TableColumn[] = [ + { + column: 'downloads', + label: 'Downloads', + type: { + type: 'menu', + icon: 'download', + options: 'downloadOptions', + tooltip: 'Download files', + }, + }, + ]; + const menuRows: TableRow[] = [ + { + id: 'row-1', + downloads: 'Open menu', + downloadOptions: [{ id: 'opt-1', name: 'CSV', icon: 'download', url: 'https://example.com/data.csv' }], + }, + ]; + + const { fixture, user } = await setup({ rows: menuRows, columns: menuColumns }); + const hoverSpy = jest.fn(); + fixture.componentInstance.downloadHovered.subscribe(hoverSpy); + + const menuButton = screen.getAllByRole('button')[0]; + await user.hover(menuButton); + + expect(hoverSpy).toHaveBeenCalledWith('row-1'); + }); + + it('should download file with filename parsed from url', async () => { + const { fixture } = await setup({ rows: TABLE_ROWS, columns: TABLE_COLUMNS }); + + fixture.componentInstance.download('https://example.com/reports/export.csv'); + + expect(saveAs).toHaveBeenCalledWith('https://example.com/reports/export.csv', 'export.csv'); + }); + + it('should open data exploration preview dialog with title and image url', async () => { + const open = jest.fn(); + const close = jest.fn(); + open.mockReturnValue({ close }); + + const explorationColumns: TableColumn[] = [ + { + column: 'exploreUrl', + label: 'Explore', + type: { + type: 'dataExploration', + titleColumn: 'title', + imageUrlColumn: 'preview', + icon: 'preview', + }, + }, + ]; + const explorationRows: TableRow[] = [ + { + exploreUrl: 'https://example.com/explore/1', + title: 'Sample Dataset', + preview: 'https://example.com/image.png', + }, + ]; + + const { user } = await setup({ rows: explorationRows, columns: explorationColumns }, [ + { provide: MatDialog, useValue: { open } }, + ]); + + await user.click(screen.getByRole('button', { name: 'Preview Sample Dataset' })); + + expect(open).toHaveBeenCalled(); + const [, config] = open.mock.calls[0]; + expect(config.data.title).toBe('Sample Dataset'); + expect(config.data.url).toBe('https://example.com/image.png'); + }); }); diff --git a/libs/design-system/table/src/lib/table/table.component.ts b/libs/design-system/table/src/lib/table/table.component.ts index d1eb6b81f4..0b8a74f46c 100644 --- a/libs/design-system/table/src/lib/table/table.component.ts +++ b/libs/design-system/table/src/lib/table/table.component.ts @@ -33,6 +33,7 @@ import { NgScrollbar } from 'ngx-scrollbar'; import { parse } from 'papaparse'; import { DataExplorationColumnType, + DateColumnType, IconColumnType, LinkColumnType, MarkdownColumnType, @@ -80,6 +81,22 @@ export class TextRowElementDirective { } } +/** Directive for typing the context of Date Row Element */ +@Directive({ + selector: 'ng-template[hraDateRowElement]', +}) +export class DateRowElementDirective { + /* istanbul ignore next */ + + /** Guard for the context of Date Row Element */ + static ngTemplateContextGuard( + _dir: DateRowElementDirective, + _ctx: unknown, + ): _ctx is RowElementContext { + return true; + } +} + /** Directive for typing the context of Link Row Element */ @Directive({ selector: 'ng-template[hraLinkRowElement]', @@ -226,6 +243,9 @@ export class TableComponent { /** Enables sorting */ readonly enableSort = input(false); + /** Initial sort configuration */ + readonly initialSort = input<{ active: string; direction: 'asc' | 'desc' }>(); + /** Enables dividers between columns */ readonly verticalDividers = input(false); @@ -388,17 +408,17 @@ export class TableComponent { * @param options Menu options * @returns Menu options as an array of MenuOptionsType */ - getMenuOptions(options: string | number | boolean | MenuOptionsType[]): MenuOptionsType[] { + getMenuOptions(options: string | Date | number | boolean | MenuOptionsType[]): MenuOptionsType[] { return options as MenuOptionsType[]; } /** Emits a route as string when object label is clicked */ - routeClick(url: string | number | boolean | (string | number | boolean)[]): void { + routeClick(url: string | Date | number | boolean | (string | Date | number | boolean)[]): void { this.routeClicked.emit(url as string); } /** Emits the id of a row when its download button is hovered */ - downloadButtonHover(id: string | number | boolean | (string | number | boolean)[]): void { + downloadButtonHover(id: string | Date | number | boolean | (string | Date | number | boolean)[]): void { this.downloadHovered.emit(id as string); } diff --git a/libs/design-system/table/src/lib/types/page-table.schema.ts b/libs/design-system/table/src/lib/types/page-table.schema.ts index 99c70d1201..fd4d32c4d4 100644 --- a/libs/design-system/table/src/lib/types/page-table.schema.ts +++ b/libs/design-system/table/src/lib/types/page-table.schema.ts @@ -12,7 +12,7 @@ export type TableRow = z.infer; /** Schema for a single table row */ export const TableRowSchema = z - .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.any())])) + .record(z.string(), z.union([z.string(), z.date(), z.number(), z.boolean(), z.array(z.any())])) .meta({ id: 'TableRow' }); /** Type for Text Column */ @@ -25,6 +25,16 @@ export const TextColumnTypeSchema = z }) .meta({ id: 'TextColumnType' }); +/** Type for Date Column */ +export type DateColumnType = z.infer; + +/** Schema for Date Column */ +export const DateColumnTypeSchema = z + .object({ + type: z.literal('date'), + }) + .meta({ id: 'DateColumnType' }); + /** Type for Numeric Column */ export type NumericColumnType = z.infer; @@ -116,6 +126,7 @@ export const MenuOptionsTypeSchema = z /** Union of Schema Types for Simple Columns */ export const SimpleTableColumnTypeSchema = z.union([ TextColumnTypeSchema.shape.type, + DateColumnTypeSchema.shape.type, NumericColumnTypeSchema.shape.type, MarkdownColumnTypeSchema.shape.type, IconColumnTypeSchema.shape.type, @@ -129,6 +140,7 @@ export type TableColumnType = z.infer; /** Union of Schema Types for Table Columns */ export const TableColumnTypeSchema = z.union([ TextColumnTypeSchema, + DateColumnTypeSchema, NumericColumnTypeSchema, MarkdownColumnTypeSchema, LinkColumnTypeSchema,