diff --git a/aries-core/src/js/components/core/Identifier/Identifier.js b/aries-core/src/js/components/core/Identifier/Identifier.js index a8df49980..b27eaa7ec 100644 --- a/aries-core/src/js/components/core/Identifier/Identifier.js +++ b/aries-core/src/js/components/core/Identifier/Identifier.js @@ -1,6 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; - import { Box, Heading, Text } from 'grommet'; export const Identifier = ({ @@ -13,7 +12,7 @@ export const Identifier = ({ }) => ( {children} - + {level ? ( {title} diff --git a/aries-site/package.json b/aries-site/package.json index aa7eca978..8f116d028 100644 --- a/aries-site/package.json +++ b/aries-site/package.json @@ -6,10 +6,10 @@ "main": "src/pages/index.js", "private": true, "dependencies": { - "aries-core": "*", "@mdx-js/loader": "^2.3.0", "@mdx-js/react": "^2.3.0", "@next/mdx": "^13.4.4", + "aries-core": "*", "next": "13.4.4", "react-ga": "^2.7.0", "react-syntax-highlighter": "^15.4.4", diff --git a/aries-site/src/components/cards/ContentCard.js b/aries-site/src/components/cards/ContentCard.js index 3777c0dc9..fce169b16 100644 --- a/aries-site/src/components/cards/ContentCard.js +++ b/aries-site/src/components/cards/ContentCard.js @@ -1,15 +1,28 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useContext } from 'react'; import PropTypes from 'prop-types'; import { Box, CardBody, Image, Text } from 'grommet'; import { Identifier } from 'aries-core'; import { PreviewImageCard } from './PreviewCard'; import { LinkCard } from './LinkCard'; import { useDarkMode } from '../../utils'; +import { pageVisitTracker } from '../../utils/pageVisitTracker'; +import { NotificationTag } from '../../layouts/content/NotificationTag'; +import { ViewContext } from '../../pages/_app'; export const ContentCard = forwardRef( ({ level, topic, minimal, ...rest }, ref) => { const { description, name, parent, preview, render } = topic; const darkMode = useDarkMode(); + + const { contentHistory } = useContext(ViewContext); + let showUpdate = false; + let changeKind; + if (contentHistory && name in contentHistory) { + // still run pageVisitTracker on it + showUpdate = pageVisitTracker(name, contentHistory); + changeKind = contentHistory[name].changeKind; + } + return ( @@ -51,9 +64,34 @@ export const ContentCard = forwardRef( level={level} > {parent && parent.icon && !minimal && ( - - {parent.icon('small', parent.color)} - {parent.name} + + + {parent.icon('small', parent.color)} + {parent.name} + + {showUpdate && changeKind === 'Update' && ( + + )} + {showUpdate && changeKind === 'New' && ( + + )} )} diff --git a/aries-site/src/components/seo/Meta.js b/aries-site/src/components/seo/Meta.js index ee02962c9..58231bded 100644 --- a/aries-site/src/components/seo/Meta.js +++ b/aries-site/src/components/seo/Meta.js @@ -19,7 +19,7 @@ export const Meta = ({ const pageContent = 'products'; const csp = `default-src 'self' 'unsafe-eval'; style-src 'self' *.hpe.com/hfws-static/5/css/ 'unsafe-inline'; - connect-src 'self' *.githubusercontent.com/grommet/hpe-design-system/ https://www.google-analytics.com https://www.github.com/grommet/ https://eyes.applitools.com *.hpe.com/hpe/api/ https://ca1.qualtrics.com/API/v3/surveys/ https://api.spacexdata.com/; + connect-src 'self' *.githubusercontent.com/grommet/hpe-design-system/ https://api.github.com/repos/grommet/hpe-design-system/pulls https://api.github.com/repos/grommet/hpe-design-system/commits https://www.google-analytics.com https://www.github.com/grommet/ https://eyes.applitools.com *.hpe.com/hpe/api/ https://ca1.qualtrics.com/API/v3/surveys/ https://api.spacexdata.com/; media-src 'self' https://d3hq6blov2iije.cloudfront.net/media/; img-src 'self' data: https://www.google-analytics.com https://images.unsplash.com/ http://s.gravatar.com/avatar/ *.hpe.com/hfws-static/5/ https://d3hq6blov2iije.cloudfront.net/images/textures/ https://d3hq6blov2iije.cloudfront.net/images/gradients/ https://d3hq6blov2iije.cloudfront.net/images/hpe-greenlake/; script-src 'self' *.hpe.com https://www.google-analytics.com/analytics.js https://netlify-cdp-loader.netlify.app/netlify.js ${ diff --git a/aries-site/src/layouts/content/InPageNavigation.js b/aries-site/src/layouts/content/InPageNavigation.js index e5d94221e..e40cdfb5a 100644 --- a/aries-site/src/layouts/content/InPageNavigation.js +++ b/aries-site/src/layouts/content/InPageNavigation.js @@ -3,7 +3,9 @@ import Link from 'next/link'; import PropTypes from 'prop-types'; import { Box, Button, Nav, Text } from 'grommet'; import styled, { ThemeContext } from 'styled-components'; +import { StatusGoodSmall } from 'grommet-icons'; import { nameToSlug } from '../../utils'; +import { ViewContext } from '../../pages/_app'; const SectionButton = styled(Button)` border-radius: 0 ${props => props.theme.global.edgeSize.xsmall} @@ -44,7 +46,7 @@ const useActiveHeadingId = (headings, options) => { return activeHeadingId; }; -export const InPageNavigation = ({ headings }) => { +export const InPageNavigation = ({ headings, title }) => { const theme = useContext(ThemeContext); let { large, medium } = theme.global.edgeSize; @@ -61,6 +63,8 @@ export const InPageNavigation = ({ headings }) => { // align "Jump to section" with page title at start const marginTop = `${large + medium}px`; + const { pageUpdateReady, contentHistory } = useContext(ViewContext); + return ( { if (level.length > 3) subsectionPad = 'large'; else if (level.length === 3) subsectionPad = 'medium'; + let sectionList; + let showUpdate = false; + + if ( + contentHistory && + title in contentHistory && + contentHistory[title].update && + contentHistory[title].sections[0].length > 0 + ) { + sectionList = contentHistory[title].sections; + Object.values(sectionList).forEach(val => { + if (val.toLowerCase() === headingTitle.toLowerCase()) { + showUpdate = true; + } + }); + } + return ( @@ -123,10 +144,23 @@ export const InPageNavigation = ({ headings }) => { ? undefined : { left: theme.global.borderSize.small } } + direction="row" + align="top" + gap="small" > {headingTitle} + {showUpdate && pageUpdateReady && ( + + + + )} @@ -140,4 +174,5 @@ export const InPageNavigation = ({ headings }) => { InPageNavigation.propTypes = { headings: PropTypes.arrayOf(PropTypes.array), + title: PropTypes.string, }; diff --git a/aries-site/src/layouts/content/NotificationTag.js b/aries-site/src/layouts/content/NotificationTag.js new file mode 100644 index 000000000..e26db03f3 --- /dev/null +++ b/aries-site/src/layouts/content/NotificationTag.js @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import { Tag } from 'grommet'; + +export const NotificationTag = ({ backgroundColor, ...rest }) => { + return ( + + ); +}; + +NotificationTag.propTypes = { + backgroundColor: PropTypes.string, +}; diff --git a/aries-site/src/layouts/content/UpdateNotification.js b/aries-site/src/layouts/content/UpdateNotification.js new file mode 100644 index 000000000..639264d0a --- /dev/null +++ b/aries-site/src/layouts/content/UpdateNotification.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import { Anchor, Text, Notification } from 'grommet'; +import { useContext } from 'react'; +import { ViewContext } from '../../pages/_app'; + +export const UpdateNotification = ({ name }) => { + const { contentHistory } = useContext(ViewContext); + const updateDate = contentHistory[name]?.date; + + if (contentHistory && name in contentHistory) { + return ( + + {`${contentHistory[name]?.description} `} + {contentHistory[name]?.action?.length > 1 && ( + + )} + + ) : ( + + This item is new. Let the Design System team know if you have any + feedback. + + ) + } + title={`${ + contentHistory[name]?.changeKind === 'Update' + ? 'Updated ' + : 'Added on ' + }${Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format( + new Date(updateDate), + )}`} + /> + ); + } + return null; +}; + +UpdateNotification.propTypes = { + name: PropTypes.string, +}; diff --git a/aries-site/src/layouts/main/Layout.js b/aries-site/src/layouts/main/Layout.js index 7b35ab976..6db144dd0 100644 --- a/aries-site/src/layouts/main/Layout.js +++ b/aries-site/src/layouts/main/Layout.js @@ -38,6 +38,8 @@ import { import { Config } from '../../../config'; import { getRelatedContent, getPageDetails } from '../../utils'; import { siteContents } from '../../data/search/contentForSearch'; +import { UpdateNotification } from '../content/UpdateNotification'; +import { ViewContext } from '../../pages/_app'; export const Layout = ({ backgroundImage, @@ -127,6 +129,15 @@ export const Layout = ({ { id: 'main', label: 'Main Content' }, ].filter(link => link !== undefined); + const { contentHistory, pageUpdateReady, setPageUpdateReady } = + useContext(ViewContext); + + // every time a new page loads, initalize ready + // state to false, until app.js declares otherwise + useEffect(() => { + setPageUpdateReady(false); + }, [setPageUpdateReady, title]); + return ( <> {/* When a backgroundImage is present, the main page content becomes @@ -164,7 +175,7 @@ export const Layout = ({ {showInPageNav ? ( - + ) : undefined} + {pageUpdateReady && contentHistory[title]?.update && ( + + )} {children} {relatedContent.length > 0 && ( diff --git a/aries-site/src/layouts/navigation/SearchResult.js b/aries-site/src/layouts/navigation/SearchResult.js index 89ffb9b02..7d6e7e167 100644 --- a/aries-site/src/layouts/navigation/SearchResult.js +++ b/aries-site/src/layouts/navigation/SearchResult.js @@ -1,13 +1,28 @@ import PropTypes from 'prop-types'; import { Box, Paragraph, Text } from 'grommet'; +import { useContext } from 'react'; import { getPageDetails } from '../../utils'; import { HighlightPhrase } from '../../components'; +import { pageVisitTracker } from '../../utils/pageVisitTracker'; +import { NotificationTag } from '../content/NotificationTag'; +import { ViewContext } from '../../pages/_app'; + export const SearchResult = ({ query, result }) => { const hub = result.url && result.url.split('/')[1]; const parent = getPageDetails(hub); + const { contentHistory } = useContext(ViewContext); + let showUpdate; + let changeKind; + if (result.title in contentHistory) { + showUpdate = pageVisitTracker(result.title, contentHistory); + changeKind = contentHistory[result.title].changeKind; + } else { + showUpdate = false; + } + return ( <> @@ -20,11 +35,29 @@ export const SearchResult = ({ query, result }) => { )} {result.title && ( - - - {result.title} - - + + + + {result.title} + + + {showUpdate && changeKind === 'New' && ( + + )} + {showUpdate && changeKind === 'Update' && ( + + )} + )} {result.matches?.length > 0 ? ( diff --git a/aries-site/src/pages/_app.js b/aries-site/src/pages/_app.js index 7d609ee52..26bcb78a8 100644 --- a/aries-site/src/pages/_app.js +++ b/aries-site/src/pages/_app.js @@ -1,8 +1,13 @@ import { MDXProvider } from '@mdx-js/react'; import PropTypes from 'prop-types'; -import React, { useEffect } from 'react'; +import React, { useEffect, createContext, useState, useMemo } from 'react'; import { Layout, ThemeMode } from '../layouts'; import { components } from '../components'; +import { + pageVisitTracker, + getLocalStorageKey, +} from '../utils/pageVisitTracker'; +import { nameToSlug } from '../utils'; const slugToText = str => str.split('-').join(' '); @@ -59,15 +64,148 @@ const backgroundImages = { * The `Component` prop is the active `page`, so whenever you * navigate between routes, `Component` will change to the new `page`. */ + +export const ViewContext = createContext(undefined); + +// thirtyDaysAgo calculated in milliseconds +const thirtyDaysAgo = new Date().getTime() - 30 * 24 * 60 * 60 * 1000; +const notificationHeading = '#### Notifications\r\n'; + function App({ Component, pageProps, router }) { const route = router.route.split('/'); - // necessary to ensure SkipLinks can receive first tab focus - // after a route change + // state that holds the update information within the last 30 days + const [contentHistory, setContentHistory] = useState({}); + // state that holds boolean for whether or not + // update info is ready to be rendered + const [pageUpdateReady, setPageUpdateReady] = useState(false); + + // this effect is only for the first time _app mounts + useEffect(() => { + const routeParts = router.route.split('/'); + let name = routeParts[routeParts.length - 1].split('#')[0]; + name = name.charAt(0).toUpperCase() + name.slice(1); + + fetch( + 'https://api.github.com/repos/grommet/hpe-design-system/pulls?state=closed', + ) + .then(response => response.json()) + .then(data => { + const nextHistory = {}; + let localStorageKey; + + for (let i = 0; i < data.length; i += 1) { + const prDescription = data[i].body; + const mergedAt = data[i].merged_at; + // PR was merged within the last 30 days and a notification + // is flagged in the PR descrription + if ( + new Date(mergedAt).getTime() > thirtyDaysAgo && + prDescription && + prDescription.includes(notificationHeading) + ) { + const indexOfFirstComponent = + prDescription.search(notificationHeading) + + notificationHeading.length; + // the position of the first bracket containing + // either new/updates is 22 characters away + // this includes the notification header + // and the \r\n\r\n that follow + const notificationsParts = prDescription + .slice(indexOfFirstComponent) + // splits them into an array jumping between name, + // sections, and description like: ['[New]Header', ['Usage', + // 'Some Section'], '[The description for header]', + // '[Update]Button', ['Dos and Donts'], '[Description + // for button]'] + .split('\r\n\r\n'); + + const regExp = /\[([^)]+)\]/; + // += 3 so it can jump between component descriptions + for ( + let j = 0; + j < Object.keys(notificationsParts).length; + j += 3 + ) { + // changeKindAndName is in format: [Update]Button or [New]Button + // where Button is the page name + const changeKindAndName = notificationsParts[j].trim(); + const changeKind = regExp.exec(changeKindAndName)[0]; + + // removes the [Update] or [New] + const pageName = + changeKindAndName && + // [Update] + changeKind && + changeKindAndName.slice(changeKind.length).trim(); + + if (pageName && !(pageName in nextHistory)) { + const sections = notificationsParts[j + 1] + .slice(1, -1) + .split(']['); + + let href; + if (sections.length === 1) { + // add an active link if only one section has been updated + href = `#${nameToSlug(sections[0].trim())}`; + } + + let showUpdate; + localStorageKey = getLocalStorageKey(pageName); + if (window.localStorage.getItem(localStorageKey)) { + showUpdate = + window.localStorage.getItem(localStorageKey) < + new Date(mergedAt).getTime(); + } else { + // user has never visited the page before + showUpdate = true; + } + nextHistory[pageName] = { + changeKind: regExp.exec(changeKindAndName)[1].trim(), + description: notificationsParts[j + 2].slice(1, -1), + date: mergedAt, + sections, + action: href, + update: showUpdate, + }; + } + } + } + } + setContentHistory(nextHistory); + // set page status as ready since all calculations are complete now + setPageUpdateReady(true); + if (name) { + localStorageKey = getLocalStorageKey(name); + const dateNow = new Date().getTime(); + window.localStorage.setItem(localStorageKey, dateNow); + } + }) + .catch(error => console.error(error)); + }, [router]); + useEffect(() => { const handleRouteChange = () => { const skipLinks = document.querySelector('#skip-links'); skipLinks.focus(); + + if (typeof window !== 'undefined') { + const routeParts = router.route.split('/'); + let name = routeParts[routeParts.length - 1]; + name = name.charAt(0).toUpperCase() + name.slice(1); + const localStorageKey = getLocalStorageKey(name); + const now = new Date().getTime(); + // every time it re-routes, see if the given page has a + // reported update in the last 30 days (what's reported in + // updateHistory) then check if it should be shown (T/F), and + // set that in the state variable + if (contentHistory && name in contentHistory) { + contentHistory[name].update = pageVisitTracker(name, contentHistory); + window.localStorage.setItem(localStorageKey, now); + setContentHistory(contentHistory); + setPageUpdateReady(true); + } + } }; router.events.on('routeChangeComplete', handleRouteChange); @@ -77,7 +215,7 @@ function App({ Component, pageProps, router }) { return () => { router.events.off('routeChangeComplete', handleRouteChange); }; - }, [router.events]); + }, [router.events, contentHistory, router.route]); // final array item from the route is the title of page we are on const title = @@ -89,20 +227,26 @@ function App({ Component, pageProps, router }) { route[route.length - 2].length && slugToText(route[route.length - 2]); + const viewContextValue = useMemo(() => { + return { contentHistory, pageUpdateReady, setPageUpdateReady }; + }, [contentHistory, pageUpdateReady]); + return ( - - - - - + + + + + + + ); } diff --git a/aries-site/src/utils/pageVisitTracker.js b/aries-site/src/utils/pageVisitTracker.js new file mode 100644 index 000000000..083db142e --- /dev/null +++ b/aries-site/src/utils/pageVisitTracker.js @@ -0,0 +1,33 @@ +export const getLocalStorageKey = title => + `${title?.toLowerCase().replace(/\s+/g, '-')}-last-visited`; + +// determines whether or not the update should be shown given +// the most recent commit and last-visit records +export const pageVisitTracker = (title, contentHistory) => { + // the name associated with the values in localStorage + const localStorageKey = getLocalStorageKey(title); + let showUpdate; + if (contentHistory && title in contentHistory) { + // there has been a reported update within the last 30 days + if (window.localStorage.getItem(localStorageKey)) { + // there has been a guaranteed update and we've seen this page before + if ( + window.localStorage.getItem(localStorageKey) > + new Date(contentHistory[title].date).getTime() + ) { + // if when they saw the page is after the update, dont show it + showUpdate = false; + } else { + // page has reported an update within 30 days, it has + // been seen before, but it was before the update was released + showUpdate = true; + } + } else { + // it's within 30 days but it has never seen the page before + showUpdate = true; + } + } else { + showUpdate = false; // no update, nothing reported in the PRs + } + return showUpdate; +};