Skip to content

Commit

Permalink
VACMS-16638: Static-Data-File Generation (KISS) (#335)
Browse files Browse the repository at this point in the history
Co-authored-by: Tanner Heffner <[email protected]>
  • Loading branch information
ryguyk and tjheffner authored Jan 12, 2024
1 parent f70b1d3 commit 437939a
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 43 deletions.
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.
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.
} 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,
}
}
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[]
}
}
}

0 comments on commit 437939a

Please sign in to comment.