From 1c8492d9737f1885a0721d26c859332d992288cc Mon Sep 17 00:00:00 2001 From: Damien Vitrac Date: Wed, 15 May 2024 16:58:45 +0200 Subject: [PATCH] Add history feature See gh-1470 --- start-client/package.json | 1 + start-client/src/components/Application.js | 20 +- .../src/components/common/history/History.js | 23 ++ .../src/components/common/history/Modal.js | 213 ++++++++++++++++++ .../src/components/common/history/Utils.js | 46 ++++ .../src/components/common/history/index.js | 1 + .../src/components/common/icons/Icons.js | 31 +++ .../src/components/common/icons/index.js | 1 + .../src/components/common/layout/SideLeft.js | 20 +- start-client/src/components/reducer/App.js | 34 ++- .../reducer/__tests__/Initializr.js | 2 +- start-client/src/components/utils/ApiUtils.js | 3 + start-client/src/styles/_dark.scss | 7 +- start-client/src/styles/_main.scss | 35 ++- start-client/src/styles/history.scss | 120 ++++++++++ start-client/yarn.lock | 5 + 16 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 start-client/src/components/common/history/History.js create mode 100644 start-client/src/components/common/history/Modal.js create mode 100644 start-client/src/components/common/history/Utils.js create mode 100644 start-client/src/components/common/history/index.js create mode 100644 start-client/src/styles/history.scss diff --git a/start-client/package.json b/start-client/package.json index 5fe0092f8b7..5dbcaa0783c 100644 --- a/start-client/package.json +++ b/start-client/package.json @@ -92,6 +92,7 @@ "js-search": "^2.0.0", "jszip": "^3.6.0", "lodash": "^4.17.21", + "luxon": "^3.4.4", "prism-react-renderer": "^1.2.0", "prismjs": "^1.24.0", "prop-types": "^15.7.2", diff --git a/start-client/src/components/Application.js b/start-client/src/components/Application.js index 96405205670..7862e98fda3 100644 --- a/start-client/src/components/Application.js +++ b/start-client/src/components/Application.js @@ -23,6 +23,7 @@ import { getConfig, getInfo, getProject } from './utils/ApiUtils' const Explore = lazy(() => import('./common/explore/Explore')) const Share = lazy(() => import('./common/share/Share')) +const History = lazy(() => import('./common/history/History')) const HotKeys = lazy(() => import('./common/builder/HotKeys')) export default function Application() { @@ -32,6 +33,7 @@ export default function Application() { theme, share: shareOpen, explore: exploreOpen, + history: historyOpen, list, dependencies, } = useContext(AppContext) @@ -78,6 +80,7 @@ export default function Application() { setGenerating(false) if (project) { FileSaver.saveAs(project, `${get(values, 'meta.artifact')}.zip`) + dispatch({ type: 'ADD_HISTORY', payload: share }) } } @@ -102,7 +105,13 @@ export default function Application() { setBlob(null) dispatch({ type: 'UPDATE', - payload: { list: false, share: false, explore: false, nav: false }, + payload: { + list: false, + share: false, + explore: false, + nav: false, + history: false, + }, }) } @@ -163,6 +172,15 @@ export default function Application() { onClose={onEscape} /> + + + + ) } diff --git a/start-client/src/components/common/history/History.js b/start-client/src/components/common/history/History.js new file mode 100644 index 00000000000..498c068937e --- /dev/null +++ b/start-client/src/components/common/history/History.js @@ -0,0 +1,23 @@ +import '../../../styles/history.scss' + +import PropTypes from 'prop-types' +import React from 'react' + +import Modal from './Modal' +import { Overlay } from '../form' + +function History({ open, onClose }) { + return ( + <> + + + + ) +} + +History.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} + +export default History diff --git a/start-client/src/components/common/history/Modal.js b/start-client/src/components/common/history/Modal.js new file mode 100644 index 00000000000..941b11564c9 --- /dev/null +++ b/start-client/src/components/common/history/Modal.js @@ -0,0 +1,213 @@ +import PropTypes from 'prop-types' +import get from 'lodash/get' +import React, { useEffect, useRef, useContext, useMemo } from 'react' +import { CSSTransition, TransitionGroup } from 'react-transition-group' +import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock' +import { AppContext } from '../../reducer/App' +import { Transform } from './Utils' +import queryString from 'query-string' + +function HistoryDate({ label, items, onClose }) { + return ( + <> +
{label}
+
    + {items.map(item => ( + + ))} +
+ + ) +} + +HistoryDate.propTypes = { + label: PropTypes.string.isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + time: PropTypes.string, + value: PropTypes.string, + }) + ), + onClose: PropTypes.func.isRequired, +} + +HistoryDate.defaultProps = { + items: [], +} + +function getLabelFromList(list, key) { + return list.find(item => item.key === key)?.text || key +} + +function getLabelFromDepsList(list, key) { + return list.find(item => item.id === key)?.name || key +} + +function HistoryItem({ time, value, onClose }) { + const { config } = useContext(AppContext) + const params = queryString.parse(value) + const deps = get(params, 'dependencies', '') + .split(',') + .filter(dep => !!dep) + return ( +
  • + { + onClose() + }} + > + {time} + + + Project{' '} + + {getLabelFromList( + get(config, 'lists.project'), + get(params, 'type') + )} + + {`, `} + Language{' '} + + {getLabelFromList( + get(config, 'lists.language'), + get(params, 'language') + )} + + {`, `} + Spring Boot{' '} + + {getLabelFromList( + get(config, 'lists.boot'), + get(params, 'platformVersion') + )} + + + + {deps.length === 0 && 'No dependencies'} + {deps.length > 0 && ( + <> + Dependencies:{' '} + + {deps + .map(dep => + getLabelFromDepsList( + get(config, 'lists.dependencies'), + dep + ) + ) + .join(', ')} + + + )} + + + +
  • + ) +} + +HistoryItem.propTypes = { + time: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, +} + +function Modal({ open, onClose }) { + const wrapper = useRef(null) + const { histories, dispatch } = useContext(AppContext) + + const historiesTransform = useMemo(() => Transform(histories), [histories]) + + useEffect(() => { + const clickOutside = event => { + const children = get(wrapper, 'current') + if (children && !children.contains(event.target)) { + onClose() + } + } + document.addEventListener('mousedown', clickOutside) + return () => { + document.removeEventListener('mousedown', clickOutside) + } + }, [onClose]) + + useEffect(() => { + if (get(wrapper, 'current') && open) { + disableBodyScroll(get(wrapper, 'current')) + } + return () => { + clearAllBodyScrollLocks() + } + }, [wrapper, open]) + + return ( + + {open && ( + + + + )} + + ) +} + +Modal.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} + +export default Modal diff --git a/start-client/src/components/common/history/Utils.js b/start-client/src/components/common/history/Utils.js new file mode 100644 index 00000000000..6df683cd522 --- /dev/null +++ b/start-client/src/components/common/history/Utils.js @@ -0,0 +1,46 @@ +import { DateTime } from 'luxon' + +function isToday(date) { + return date.hasSame(DateTime.now(), 'day') +} + +function isYesterday(date) { + return date.hasSame(DateTime.now().minus({ days: 1 }), 'day') +} + +export function Transform(histories) { + if (histories.length === 0) { + return [] + } + const parsed = histories.map(history => { + const dateLuxon = DateTime.fromISO(history.date) + let label = '' + if (isToday(dateLuxon)) { + label = 'Today, ' + } else if (isYesterday(dateLuxon)) { + label = 'Yesterday, ' + } + return { + date: dateLuxon, + time: `${dateLuxon.toFormat('HH:mm')}`, + label: `${label}${dateLuxon.toFormat('cccc, d LLLL yyyy')}`, + value: history.value, + } + }) + return parsed.reduce((acc, history) => { + if (acc.length === 0) { + acc.push({ + label: history.label, + histories: [history], + }) + } else if (acc[acc.length - 1].label === history.label) { + acc[acc.length - 1].histories.push(history) + } else { + acc.push({ + label: history.label, + histories: [history], + }) + } + return acc + }, []) +} diff --git a/start-client/src/components/common/history/index.js b/start-client/src/components/common/history/index.js new file mode 100644 index 00000000000..2efd9c0c11f --- /dev/null +++ b/start-client/src/components/common/history/index.js @@ -0,0 +1 @@ +export { default as History } from './History' diff --git a/start-client/src/components/common/icons/Icons.js b/start-client/src/components/common/icons/Icons.js index 1a2e0d153fa..9318ca56f1b 100644 --- a/start-client/src/components/common/icons/Icons.js +++ b/start-client/src/components/common/icons/Icons.js @@ -368,3 +368,34 @@ export function IconEnter() { ) } + +export function IconHistory() { + return ( + + ) +} diff --git a/start-client/src/components/common/icons/index.js b/start-client/src/components/common/icons/index.js index f8010ec116a..7fa600b4088 100644 --- a/start-client/src/components/common/icons/index.js +++ b/start-client/src/components/common/icons/index.js @@ -16,5 +16,6 @@ export { IconSun } from './Icons' export { IconMoon } from './Icons' export { IconRemove } from './Icons' export { IconEnter } from './Icons' +export { IconHistory } from './Icons' export { IconSpring } from './IconSpring' diff --git a/start-client/src/components/common/layout/SideLeft.js b/start-client/src/components/common/layout/SideLeft.js index c8ca6aa0caa..12596f1edaa 100644 --- a/start-client/src/components/common/layout/SideLeft.js +++ b/start-client/src/components/common/layout/SideLeft.js @@ -5,14 +5,14 @@ import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock' import Header from './Header' import { AppContext } from '../../reducer/App' -import { IconGithub } from '../icons' +import { IconGithub, IconHistory } from '../icons' function SideLeft() { const [isOpen, setIsOpen] = useState(false) const [lock, setLock] = useState(false) const wrapper = useRef(null) - const { nav, dispatch } = useContext(AppContext) + const { nav, histories, dispatch } = useContext(AppContext) useEffect(() => { if (get(wrapper, 'current') && nav) { @@ -61,6 +61,22 @@ function SideLeft() { + {!isOpen && !lock && histories.length > 0 && ( + <> +
    + + + )}
    {}, + setItem: () => {}, + } + export function reduceDependencies(boot, items) { const groups = [] const list = [] @@ -96,7 +107,26 @@ export function reducer(state, action) { get(json, 'defaultValues.boot'), get(json, 'lists.dependencies') ) - return { ...state, complete: true, config: json, dependencies } + const histories = localStorage.getItem('histories') + ? JSON.parse(localStorage.getItem('histories')) + : [] + return { ...state, complete: true, config: json, dependencies, histories } + } + case 'ADD_HISTORY': { + const newHistory = get(action, 'payload') + const histories = [ + { + date: new Date().toISOString(), + value: newHistory, + }, + ...state.histories.slice(0, MAX_HISTORY - 1), + ] + localStorage.setItem('histories', JSON.stringify(histories)) + return { ...state, histories } + } + case 'CLEAR_HISTORY': { + localStorage.setItem('histories', JSON.stringify([])) + return { ...state, histories: [] } } default: return state diff --git a/start-client/src/components/reducer/__tests__/Initializr.js b/start-client/src/components/reducer/__tests__/Initializr.js index 20ef2bdd15d..23f43b046eb 100644 --- a/start-client/src/components/reducer/__tests__/Initializr.js +++ b/start-client/src/components/reducer/__tests__/Initializr.js @@ -47,7 +47,7 @@ describe('COMPLETE action', () => { }, }) expect(get(result, 'share')).toBe( - 'type=maven-project&language=java&platformVersion=2.2.0.RELEASE&packaging=jar&jvmVersion=1.8&groupId=com.example&artifactId=demo&name=demo&description=Demo%20project%20for%20Spring%20Boot&packageName=com.example.demo' + 'type=maven-project&language=java&platformVersion=2.2.0.RELEASE&packaging=jar&jvmVersion=1.8&groupId=com.example&artifactId=demo&name=demo&description=Demo%20project%20for%20Spring%20Boot&packageName=com.example.demo&dependencies=' ) expect(get(result, 'values.project')).toBe('maven-project') expect(get(result, 'values.language')).toBe('java') diff --git a/start-client/src/components/utils/ApiUtils.js b/start-client/src/components/utils/ApiUtils.js index 7de37d1ae85..15cdbb8f50d 100644 --- a/start-client/src/components/utils/ApiUtils.js +++ b/start-client/src/components/utils/ApiUtils.js @@ -54,6 +54,8 @@ export const getShareUrl = values => { let params = `${querystring.stringify(props)}` if (get(values, 'dependencies', []).length > 0) { params = `${params}&dependencies=${get(values, 'dependencies').join(',')}` + } else { + params = `${params}&dependencies=` } return params } @@ -151,6 +153,7 @@ export const parseParams = (values, queryParams, lists) => { const depsWarning = [] const newVal = value .split(',') + .filter(item => !!item) .map(item => { const dep = get(lists, 'dependencies').find( d => d.id === item.trim() diff --git a/start-client/src/styles/_dark.scss b/start-client/src/styles/_dark.scss index 984ceaf0561..58a0127dbab 100644 --- a/start-client/src/styles/_dark.scss +++ b/start-client/src/styles/_dark.scss @@ -16,6 +16,10 @@ body.dark { border-color: rgba(255, 255, 255, 0.2); } + #side-left .navigation-item { + color: white; + } + #side-right .side-container { border-color: $dark-border; background: $dark-background; @@ -321,7 +325,8 @@ body.dark { #side-left a, .explorer-ul .file, .explorer-ul .folder, - ul.dependencies-list li a { + ul.dependencies-list li a, + #side-left .navigation-item { &:focus { outline: 1px dotted $dark-border; } diff --git a/start-client/src/styles/_main.scss b/start-client/src/styles/_main.scss index 70ce292e0e2..af24fb69933 100644 --- a/start-client/src/styles/_main.scss +++ b/start-client/src/styles/_main.scss @@ -85,6 +85,39 @@ body { left: 0; width: 5rem; } + .navigation-divider { + margin: 20px; + border-top: 1px solid $light-border; + } + .navigation-item { + $size: 30px; + display: block; + margin: 0.2rem auto; + color: $light-color; + padding: 0; + cursor: pointer; + width: $size + 12px; + height: $size + 12px; + @include transition(color $spring-transition-duration); + border: 0 none; + background: none; + .icon-history { + width: 30px; + margin-top: 6px; + } + &:hover { + color: $light-primary; + } + &:focus { + outline: 1px dotted #aecaca; + } + } + &.is-open .navigation-item { + color: white; + &:hover { + opacity: 0.7; + } + } } #side-right { @@ -1316,4 +1349,4 @@ ul.dependencies-list { display: inline-block; height: 14px; background: $light-placeholder; -} \ No newline at end of file +} diff --git a/start-client/src/styles/history.scss b/start-client/src/styles/history.scss new file mode 100644 index 00000000000..9d1ace6786d --- /dev/null +++ b/start-client/src/styles/history.scss @@ -0,0 +1,120 @@ +@import 'variables'; +@import 'mixins'; + +$w_arrow: 12px; +$w: 1000px; + +.modal-share { + z-index: 10000; + position: fixed; + top: 50px; + left: 0; + right: 0; + + .modal-history-container { + max-width: $w; + margin: 0 auto; + background: white; + } + + @include transition(all $spring-transition-duration); + &:before { + $h: 60px; + content: ' '; + height: $h; + width: $w; + position: absolute; + bottom: -$h; + left: 0; + } + .modal-content { + padding: $spring-8points * 3; + padding-top: $spring-8points; + padding-bottom: $spring-8points * 2; + max-height: 70vh; + overflow: auto; + .list { + .date { + font-weight: bold; + padding: 10px 0 5px; + } + ul { + padding: 0; + margin: 0 0 10px; + } + li { + list-style: none; + padding: 1px 0; + margin: 0; + } + a.item { + position: relative; + background: $light-background-seconday; + border-radius: 3px; + display: flex; + text-decoration: none; + padding: 5px 10px; + color: $light-color; + padding-right: 60px; + &:hover { + background: lighten($light-background-seconday, 2); + a { + opacity: 1; + } + } + } + .time { + width: 80px; + } + .time, + .desc, + .main, + .deps { + display: block; + } + } + } + .modal-header { + position: relative; + padding: 6px $spring-8points * 2 2px; + border-bottom: 1px solid #ebebeb; + h1 { + font-size: $spring-8points * 2.5; + line-height: $spring-8points * 2.5; + font-weight: 600; + } + .button { + position: absolute; + top: 11px; + right: 11px; + font-size: $spring-font-size - 3; + line-height: 0.7rem; + margin-right: 0; + } + } + .modal-action { + text-align: center; + border-top: 1px solid $light-border; + padding: 16px 0 8px; + } +} + +.modal-enter { + opacity: 0; +} + +.modal-enter-active { + opacity: 1; + transition: all 300ms; +} + +.modal-exit { + opacity: 1; +} + +.modal-exit-active { + opacity: 0; + transition: all 300ms; +} + +@import 'responsive'; diff --git a/start-client/yarn.lock b/start-client/yarn.lock index d8dd9ba0889..da1fe441363 100644 --- a/start-client/yarn.lock +++ b/start-client/yarn.lock @@ -7295,6 +7295,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^3.4.4: + version "3.4.4" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" + integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== + magic-string@^0.25.0, magic-string@^0.25.7: version "0.25.9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"