Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VACMS-16638: Static-Data-File Generation (KISS) #335

Merged
merged 7 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 0 additions & 23 deletions scripts/yarn/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const target = path.resolve(
'generated'
)
const symlinkPath = path.resolve(__dirname, '..', '..', 'public', 'generated')
const cmsDataPath = path.resolve(__dirname, '..', '..', 'public', 'data', 'cms')

;(async () => {
try {
Expand All @@ -31,26 +30,4 @@ const cmsDataPath = path.resolve(__dirname, '..', '..', 'public', 'data', 'cms')
} catch (error) {
console.error('Error creating symlink:', error)
}

try {
const vamcEhrExists = await fs.pathExists(cmsDataPath)

if (!vamcEhrExists) {
// Grab data file populated by the cms
const response = await fetch('https://va.gov/data/cms/vamc-ehr.json')
const data = await response.json()

await fs.mkdirp(cmsDataPath)

await fs.writeJson(`${cmsDataPath}/vamc-ehr.json`, data)

// eslint-disable-next-line no-console
console.log('vamc-ehr data fetched successfully!')
} else {
// eslint-disable-next-line no-console
console.log('vamc-ehr data already exists.')
}
} catch (error) {
console.error('Error fetching vamc-ehr data file:', error)
}
})()
51 changes: 47 additions & 4 deletions src/data/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import * as StaticPathResources from './staticPathResources'
import * as HeaderFooter from './headerFooter'
import * as PromoBlock from './promoBlock'
import * as Event from './event'
import { RESOURCE_TYPES } from '@/lib/constants/resourceTypes'
import * as VamcEhr from './vamcEhr'
import { ResourceType } from '@/lib/constants/resourceTypes'

export const QUERIES_MAP = {
// standard Drupal entity data queries
Expand Down Expand Up @@ -46,17 +47,59 @@ export const QUERIES_MAP = {

// Static Path Generation
'static-path-resources': StaticPathResources,

// Static JSON files
'vamc-ehr': VamcEhr,
}

// Type representing all possible object shapes returned from querying and formatting Drupal data.
// E.g. StoryListingType | NewsStoryType | (other future resoource type)
// Type constructed by:
// 1. Consider all keys of QUERIES_MAP above
// 2. Take subset of those keys that appear in RESOURCE_TYPES (imported)
// 1. Consider all ResourceType types
// 2. Take subset of those types that have a key in QUERIES_MAP
// 3. Map that subset of keys to their respective values, which are modules for querying data
// 4. Within each of those modules, grab the return type of the `formatter` function
export type FormattedResource = ReturnType<
(typeof QUERIES_MAP)[(typeof RESOURCE_TYPES)[keyof typeof RESOURCE_TYPES]]['formatter']
(typeof QUERIES_MAP)[ResourceType & keyof typeof QUERIES_MAP]['formatter']
>

// Type representing all keys from QUERIES_MAP whose values have a 'data' function.
// This type is used, for example, to type values we can pass to queries.getData()
// E.g. `node--news_story` is included because src/data/queries/newsStory.ts has a function `data`
// E.g. `block--alert` is NOT included because src/data/queries/alert.ts does not have a function `data` (only `formatter`)
export type QueryType = {
[K in keyof typeof QUERIES_MAP]: 'data' extends keyof (typeof QUERIES_MAP)[K]
? K
: never
}[keyof typeof QUERIES_MAP]

// Type mapping keys from QUERIES_MAP to the types of opts passable to the respective `data` function
// of the key's value.
// This type is used, for example, to ensure that StaticJsonFile configurations define an acceptable
// value for `queryOpts`.
// E.g. `node--news_story` => NewsStoryDataOpts because the `data` function in src/data/queries/newsStory.ts
// is typed QueryData<NewsStoryDataOpts, NodeNewsStory> (note first parameter)
/*eslint-disable @typescript-eslint/no-explicit-any*/
type AllQueryDataOptsMap = {
[K in keyof typeof QUERIES_MAP]: 'data' extends keyof (typeof QUERIES_MAP)[K]
? (typeof QUERIES_MAP)[K] extends { data: (...args: infer U) => any }
? U[0]
: never
: never
}
/*eslint-enable @typescript-eslint/no-explicit-any*/
type NonNeverKeys<T> = {
[K in keyof T]: T[K] extends never ? never : K
}[keyof T]
export type QueryDataOptsMap = Pick<
AllQueryDataOptsMap,
NonNeverKeys<AllQueryDataOptsMap>
>

// Type representing resource types that have queries defined
// E.g. `node--news_story` is included because it's a resource type and has an entry in QUERIES_MAP
// E.g. `node--health_care_local_facility` is not included because it does not have an entry in QUERIES_MAP
// E.g. `vamc-ehr` is not included because it's not a resource type
export type QueryResourceType = QueryType & ResourceType

export const queries = createQueries(QUERIES_MAP)
7 changes: 2 additions & 5 deletions src/data/queries/staticPathResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,14 @@ import {
QueryParams,
} from 'next-drupal-query'
import { JsonApiResourceWithPath } from 'next-drupal'
import {
ADDITIONAL_RESOURCE_TYPES,
ResourceType,
} from '@/lib/constants/resourceTypes'
import { ResourceType } from '@/lib/constants/resourceTypes'
import { StaticPathResource } from '@/types/formatted/staticPathResource'
import { FieldAdministration } from '@/types/drupal/field_type'
import { PAGE_SIZES } from '@/lib/constants/pageSizes'
import { queries } from '.'
import { fetchAndConcatAllResourceCollectionPages } from '@/lib/drupal/query'

const PAGE_SIZE = PAGE_SIZES[ADDITIONAL_RESOURCE_TYPES.STATIC_PATHS]
const PAGE_SIZE = PAGE_SIZES.MAX

// Define the query params for fetching static paths.
export const params: QueryParams<ResourceType> = (
Expand Down
64 changes: 64 additions & 0 deletions src/data/queries/vamcEhr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { QueryData, QueryFormatter, QueryParams } from 'next-drupal-query'
import { queries } from '.'
import { RESOURCE_TYPES } from '@/lib/constants/resourceTypes'
import { fetchAndConcatAllResourceCollectionPages } from '@/lib/drupal/query'
import { VamcEhr as DrupalVamcEhr } from '@/types/drupal/vamcEhr'
import { VamcEhrGraphQLMimic } from '@/types/formatted/vamcEhr'
import { PAGE_SIZES } from '@/lib/constants/pageSizes'

const PAGE_SIZE = PAGE_SIZES.MAX

// Define the query params for fetching node--health_care_local_facility.
export const params: QueryParams<null> = () => {
return queries
.getParams()
.addFields(RESOURCE_TYPES.VAMC_FACILITY, [
'title',
'field_facility_locator_api_id',
'field_region_page',
])
.addFilter('field_main_location', '1')
.addInclude(['field_region_page'])
.addFields(RESOURCE_TYPES.VAMC_SYSTEM, ['title', 'field_vamc_ehr_system'])
}

// Implement the data loader.
export const data: QueryData<null, DrupalVamcEhr[]> = async (): Promise<
DrupalVamcEhr[]
> => {
const { data } =
await fetchAndConcatAllResourceCollectionPages<DrupalVamcEhr>(
RESOURCE_TYPES.VAMC_FACILITY,
params(),
PAGE_SIZE
)
return data
}

export const formatter: QueryFormatter<DrupalVamcEhr[], VamcEhrGraphQLMimic> = (
entities: DrupalVamcEhr[]
) => {
// For now, return data formatted as it is in content-build (mimic GraphQL output).
// In future, we should move the formatting from the preProcess in vets-website
// into this formatter and, while it exists, into postProcess in content-build.
// This change will require a coordinated effort so as to not break things with regard
// to what vets-website is expecting and what is present in the generated file.
Copy link
Contributor

Choose a reason for hiding this comment

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

completely agree with this callout, but i am kind of thinking the "future" state should not happen until w/e page type(s) that use this data have been ported to next-build. avoids the most potential drift and eases the coordinated effort back to 2 repos.

return {
data: {
nodeQuery: {
count: entities.length,
entities: entities.map((entity) => ({
title: entity.title,
fieldFacilityLocatorApiId: entity.field_facility_locator_api_id,
fieldRegionPage: {
entity: {
title: entity.field_region_page.title,
fieldVamcEhrSystem:
entity.field_region_page.field_vamc_ehr_system,
},
},
})),
},
},
}
}
7 changes: 2 additions & 5 deletions src/lib/constants/pageSizes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import {
RESOURCE_TYPES,
ADDITIONAL_RESOURCE_TYPES,
} from '@/lib/constants/resourceTypes'
import { RESOURCE_TYPES } from '@/lib/constants/resourceTypes'

export const PAGE_SIZES = {
[RESOURCE_TYPES.STORY_LISTING]: 10,
[ADDITIONAL_RESOURCE_TYPES.STATIC_PATHS]: 50, //must be <= 50 due to JSON:API limit
MAX: 50, //50 is JSON:API limit. Use this for fetching as many as possible at a time.
Copy link
Contributor

Choose a reason for hiding this comment

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

love this

} as const
6 changes: 2 additions & 4 deletions src/lib/constants/resourceTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ export const RESOURCE_TYPES = {
STORY_LISTING: 'node--story_listing',
STORY: 'node--news_story',
EVENT: 'node--event',
VAMC_FACILITY: 'node--health_care_local_facility',
VAMC_SYSTEM: 'node--health_care_region_page',
// QA: 'node--q_a',
} as const

export type ResourceType = (typeof RESOURCE_TYPES)[keyof typeof RESOURCE_TYPES]

export const ADDITIONAL_RESOURCE_TYPES = {
STATIC_PATHS: 'static-path-resources',
}
10 changes: 8 additions & 2 deletions src/lib/drupal/staticProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
LovellStaticPropsResource,
LovellFormattedResource,
} from './lovell/types'
import { FormattedResource } from '@/data/queries'
import {
FormattedResource,
QueryResourceType,
QUERIES_MAP,
} from '@/data/queries'
import { RESOURCE_TYPES, ResourceType } from '@/lib/constants/resourceTypes'
import { ListingPageDataOpts } from '@/lib/drupal/listingPages'
import { NewsStoryDataOpts } from '@/data/queries/newsStory'
Expand Down Expand Up @@ -100,7 +104,9 @@ export async function fetchSingleStaticPropsResource(
queryOpts: StaticPropsQueryOpts
): Promise<FormattedResource> {
// Request resource based on type
const resource = await queries.getData(resourceType, queryOpts)
const resource = Object.keys(QUERIES_MAP).includes(resourceType)
? await queries.getData(resourceType as QueryResourceType, queryOpts)
: null
if (!resource) {
throw new Error(`Failed to fetch resource: ${pathInfo.jsonapi.individual}`)
}
Expand Down
81 changes: 81 additions & 0 deletions src/pages/data/cms/[filename].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
GetStaticPathsContext,
GetStaticPathsResult,
GetStaticPropsContext,
} from 'next'
import fs from 'fs'
import path from 'path'
import { QueryDataOptsMap, queries } from '@/data/queries'

type StaticJsonFile<T extends keyof QueryDataOptsMap> = {
filename: string
query: T
queryOpts?: QueryDataOptsMap[T]
}

const STATIC_JSON_FILES: Array<StaticJsonFile<keyof QueryDataOptsMap>> = [
{
filename: 'vamc-ehr',
query: 'vamc-ehr',
} as StaticJsonFile<'vamc-ehr'>,

// Another example:
// {
// filename: 'hypothetical-banner-data-static-json-file',
// query: 'banner-data', //must be defined in QUERIES_MAP in src/data/queries/index.ts
// queryOpts: {
// itemPath: 'path/to/item',
// },
// } as StaticJsonFile<'banner-data'>,
]

/* This component never generates a page, but this default export must be present */
export default function StaticJsonPage() {
return null
}

export async function getStaticPaths(
context: GetStaticPathsContext
): Promise<GetStaticPathsResult> {
if (process.env.SSG === 'false') {
return {
paths: [],
fallback: 'blocking',
}
}

return {
paths: STATIC_JSON_FILES.map(({ filename }) => ({
params: {
filename,
},
})),
fallback: 'blocking',
}
}

export async function getStaticProps(context: GetStaticPropsContext) {
const staticJsonFilename = context.params?.filename
const staticJsonFile = STATIC_JSON_FILES.find(
({ filename }) => filename === staticJsonFilename
)
if (staticJsonFile) {
const { filename, query, queryOpts = {} } = staticJsonFile

// fetch data
const data = await queries.getData(query, queryOpts)

// Write to /public/data/cms
const filePath = path.resolve(`public/data/cms/${filename}.json`)
const directoryPath = path.dirname(filePath)
if (!fs.existsSync(directoryPath)) {
fs.mkdirSync(directoryPath, {
recursive: true,
})
}
fs.writeFileSync(filePath, JSON.stringify(data))
}
return {
notFound: true,
Copy link
Contributor

Choose a reason for hiding this comment

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

Noting that this 404 only triggers if someone visits SITE_URL/data/cms/[filename]. The json file path public/data/cms/[filename].json resolves on the web at SITE_URL/data/cms/[filename].json.

}
}
8 changes: 8 additions & 0 deletions src/types/drupal/vamcEhr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type VamcEhr = {
title: string
field_facility_locator_api_id: string
field_region_page: {
title: string
field_vamc_ehr_system: 'vista' | 'cerner' | 'cerner_staged'
}
}
39 changes: 39 additions & 0 deletions src/types/formatted/vamcEhr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// // FUTURE USE
// // vamc-ehr.json should eventually include formatted data.
// // At the least, it should contain formatted facilities.
// export type VamcEhrFacility = {
// title: string
// vhaId: string
// vamcFacilityName: string
// vamcSystemName: string
// ehr: 'vista' | 'cerner' | 'cerner_staged'
// }
// // Maybe addtionally, it should categorize the
// // facilities and offload this piece from vets-website code as well.
// export type VamcEhr = {
// ehrDataByVhaId: {
// vhaId: VamcEhrFacility,
// },
// cernerFacilities: VamcEhrFacility[],
// vistaFacilities: VamcEhrFacility[],
// }

export type VamcEhrGraphQLEntity = {
title: string
fieldFacilityLocatorApiId: string
fieldRegionPage: {
entity: {
title: string
fieldVamcEhrSystem: 'vista' | 'cerner' | 'cerner_staged'
}
}
}

export type VamcEhrGraphQLMimic = {
data: {
nodeQuery: {
count: number
entities: VamcEhrGraphQLEntity[]
}
}
}