diff --git a/src/api/index.ts b/src/api/index.ts index e07c71a5..a9d0f3ac 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,6 +14,7 @@ import {UploadByUrlForm, UploadForm, uploadFormToFormData} from '../components/V import {Maintenance} from '../types/Maintenance'; import {Tag} from '../types/Tag'; import {SystemMessage} from '../types/SystemMessage'; +import {SearchOptions} from 'types/SearchOptions'; let api = makeAxios(process.env.REACT_APP_API_URL); @@ -78,6 +79,18 @@ export async function getModule(id: number) { return ( await api.get(`/modules/${id}`)).data; } +export async function searchModules(searchTerm : string) { + return (await api.get(`/module/search/${searchTerm}`)).data; +} + +export async function searchOptionsModules(searchInputs : SearchOptions) { + const searchParams = { + title: searchInputs.title, description: searchInputs.description, + difficulty: searchInputs.difficulty, tags: searchInputs.tags.map(t => t.name).toString() + }; + return ( await api.get(`/module/search`, {params: searchParams}) ).data; +} + export async function getUserList() { return ( await api.get(`/user/`) ).data; } @@ -139,10 +152,38 @@ export async function getUserModules() { export async function getUserModule(id: number) { return handleResponse( await api.get(`/user-module/${id}`)).data; } +export async function searchUserModules(searchTerm : string) { + return handleResponse( await api.get(`/user-module/search/${searchTerm}`)).data; +} + +export async function searchOptionsUserModules(searchInputs : SearchOptions) { + const searchParams = { + title: searchInputs.title, description: searchInputs.description, + difficulty: searchInputs.difficulty, tags: searchInputs.tags.map(t => t.name).toString() + }; + return handleResponse( await api.get(`/user-module/search`, {params: searchParams})).data; +} + export async function getEditorsModules() { return handleResponse(await api.get(`module/modules-editor`)).data; } +export async function getAdminModules() { + return handleResponse(await api.get(`module/modules-editor/admin`)).data; +} + +export async function searchEditorsModules(searchTerm : string) { + return handleResponse( await api.get(`module/modules-editor/search/${searchTerm}`)).data; +} + +export async function searchOptionsEditorsModules(searchInputs: SearchOptions) { + const searchParams = { + title: searchInputs.title, description: searchInputs.description, + difficulty: searchInputs.difficulty, tags: searchInputs.tags.map(t => t.name).toString() + }; + return handleResponse(await api.get(`module/modules-editor/search`, {params: searchParams})).data; +} + export async function getUserLab(id: number) { return handleResponse( await api.get(`/user-lab/${id}`)).data; } diff --git a/src/components/CreatorsModuleCard/CreatorsModuleCard.module.scss b/src/components/CreatorsModuleCard/CreatorsModuleCard.module.scss index e314b00b..3b87a3b5 100644 --- a/src/components/CreatorsModuleCard/CreatorsModuleCard.module.scss +++ b/src/components/CreatorsModuleCard/CreatorsModuleCard.module.scss @@ -1,5 +1,5 @@ .card { - margin-bottom: 35px; + margin-bottom: 25px; } .card-footer a { diff --git a/src/components/CreatorsModuleCard/CreatorsModuleCard.tsx b/src/components/CreatorsModuleCard/CreatorsModuleCard.tsx index 0991d99f..cba0ac6d 100644 --- a/src/components/CreatorsModuleCard/CreatorsModuleCard.tsx +++ b/src/components/CreatorsModuleCard/CreatorsModuleCard.tsx @@ -6,7 +6,7 @@ import {WebState} from '../../redux/types/WebState'; import {connect} from 'react-redux'; import {isAuthenticated} from '../../redux/selectors/entities'; import {UserModule} from '../../types/UserModule'; -import {getLocalDateTimeString} from '../../util'; +import {getLocalDateTimeString, getModuleDifficultyLabel} from '../../util'; import CopyToClipboard from 'react-copy-to-clipboard'; import {Link} from 'react-router-dom'; import {IconButton} from '../util/IconButton/IconButton'; @@ -35,6 +35,7 @@ class CreatorsModuleCardComponent extends Component {
Published: {`${module.published}`}
Module type: {`${module.type}`}
+ Difficulty: {`${getModuleDifficultyLabel(module.difficulty)}`}
Share Link { diff --git a/src/components/ModuleCard/ModuleCard.tsx b/src/components/ModuleCard/ModuleCard.tsx index bebe2d5d..043e4756 100644 --- a/src/components/ModuleCard/ModuleCard.tsx +++ b/src/components/ModuleCard/ModuleCard.tsx @@ -7,7 +7,7 @@ import {connect} from 'react-redux'; import {isAuthenticated} from '../../redux/selectors/entities'; import {Link} from 'react-router-dom'; import {UserModule} from '../../types/UserModule'; -import {getLocalDateTimeString} from '../../util'; +import {getLocalDateTimeString, getModuleDifficultyLabel} from '../../util'; interface ModuleCardProps extends ReturnType { module: Module|UserModule; @@ -33,6 +33,7 @@ class ModuleCardComponent extends Component { {getLocalDateTimeString(module.updatedAt)} + {getModuleDifficultyLabel(module.difficulty)} {this.getStartButton()} @@ -44,11 +45,11 @@ class ModuleCardComponent extends Component { if (this.props.buttonLink) { return ( - + ); } else if (this.props.buttonAction) { - return (); + return (); } else { return null; } diff --git a/src/components/ModulesSearch/ModulesFilter.module.scss b/src/components/ModulesSearch/ModulesFilter.module.scss new file mode 100644 index 00000000..59fdfc53 --- /dev/null +++ b/src/components/ModulesSearch/ModulesFilter.module.scss @@ -0,0 +1,32 @@ +.filter-options { + position: absolute; + z-index: 1; + top: calc(75% + 6px); + right: 0; + width: 14rem; + padding: 1.5rem; + background-color: #fff; + border-radius: .25rem; + box-shadow: 0 0 8px rgb(175, 175, 175); +} + +.icon:hover { + cursor: pointer; + color: maroon; +} + +.sort-option { + margin-top: .5rem; +} + +.sort-option label { + padding-left: .5rem; +} + +.hide { + display: none; +} + +.show { + display: block !important; +} diff --git a/src/components/ModulesSearch/ModulesFilter.tsx b/src/components/ModulesSearch/ModulesFilter.tsx new file mode 100644 index 00000000..d26f0d36 --- /dev/null +++ b/src/components/ModulesSearch/ModulesFilter.tsx @@ -0,0 +1,132 @@ +import {faFilter} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {Container, Form} from 'react-bootstrap'; +import React, {useRef, useState} from 'react'; +import {connect} from 'react-redux'; +import {isAdmin, isAuthenticated, isCreator} from 'redux/selectors/entities'; +import {WebState} from 'redux/types/WebState'; +import styles from './ModulesFilter.module.scss'; +import {RoutePaths} from 'router/RoutePaths'; +import {Module} from 'types/Module'; +import {UserModule} from 'types/UserModule'; +import {getAdminModules} from 'api'; +import ToolTip from 'components/util/ToolTip'; + +export type Modules = Module[] | UserModule[]; + +interface ModulesFilterProps extends ReturnType { + modules: Modules; + loadModules(): void; + showSortedModules(modules: Modules): void; +} + +const ModulesFilter = (props: ModulesFilterProps) => { + + const creatorSortOptions = [ + props.admin ? { name: 'All Modules', value: 1 } : {}, + { name: 'Type', value: 2}, + { name: 'Published', value: 3} + ]; + + const sortOptions = [ + ...(props.creator || props.admin) && window.location.pathname === RoutePaths.contentCreator ? + [...creatorSortOptions] : [], + { name: 'Name', value: 4 }, + { name: 'Difficulty', value: 5 }, + { name: 'Date', value: 6 } + ]; + + const [sortOption, setSortOption] = useState(0); + const [filterOn, setFilterOn] = useState(false); + + const focusRef = useRef(); + + const onFilterClick = () => { + setFilterOn(!filterOn); + }; + + const onBlur = (e: any) => { + if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget)) { + setFilterOn(false); + } + }; + + const onSortChange = (e: any) => { + let selectedOption = parseInt(e.target.value, 10); + + if (selectedOption === sortOption) { + selectedOption = 0; + } + setSortOption(selectedOption); + showSortedModules(selectedOption); + }; + + const showSortedModules = (option: number) => { + const modules: Module[] = props.modules[0]['module'] !== undefined ? + (props.modules as UserModule[]).map(um => um.module) : props.modules as Module[]; + + if (option === 0) // all options unchecked, load original + props.loadModules(); + + getSortedModules(option, modules).then(response => { + props.showSortedModules(response); + }); + }; + + const getSortedModules = async (option: number, modules: Modules) => { + switch (option) { + case 1: + return getAdminModules(); + case 2: + return modules.sort( (a: any, b: any) => b.type.localeCompare(a.type) ); + case 3: + return modules.sort( (a: any, b: any) => a.published - b.published ); + case 4: + return modules.sort( (a: any, b: any) => a.name.localeCompare(b.name) ); + case 5: + return modules.sort( (a: any, b: any) => a.difficulty - b.difficulty ); + case 6: + return modules.sort( (a: any, b: any) => new Date(b?.updatedAt).getTime() - new Date(a?.updatedAt).getTime() ); + default: + return modules; + } + }; + + return ( +
focusRef.current = ref} + onBlur={onBlur} + tabIndex={-1} + > + + + + {filterOn && + + Sort by + + {sortOptions.map((option, idx) => ( + option.value && + + ))} + + + } +
+ ); +}; + +const mapUserStateToProps = (state: WebState) => ({authenticated: isAuthenticated(state), creator: isCreator(state), admin: isAdmin(state)}); +export default connect(mapUserStateToProps)(ModulesFilter); \ No newline at end of file diff --git a/src/components/ModulesSearch/ModulesSearch.module.scss b/src/components/ModulesSearch/ModulesSearch.module.scss new file mode 100644 index 00000000..004fa8f4 --- /dev/null +++ b/src/components/ModulesSearch/ModulesSearch.module.scss @@ -0,0 +1,77 @@ +.search-bar { + padding: 4px 12px; + height: auto; + min-width: 18rem; + border: 1px solid #343a40; + border-radius: 22px; + box-shadow: inset 0 -2px 0 #343a40; +} + +.difficulty-dropdown { + font-size: .930125rem; + border-radius: .25rem; + background-color: #fff; + color: #868e96; + border: 1px solid rgba(0, 0, 0, .1); +} + +.icon { + display: flex; + justify-content: center; +} + +.icon:hover { + cursor: pointer; + color: maroon; +} + +.search-options { + position: absolute; + z-index: 1; + top: calc(100% + 6px); + right: 0; + width: 28rem; + padding: 24px; + background-color: #fff; + border-radius: .25rem; + box-shadow: 0 0 8px rgb(175, 175, 175); +} + +.search-input { + height: calc(1.5em + 1rem + 6px); + min-width: 14rem; +} + +.input-row { + margin-bottom: .75rem !important; +} + +div .tags-no-border.tags-no-border { + border: none; + padding: 3px; +} + +div .tags.tags { + padding: 4px; + min-width: 110px; +} + +.selected-tag.selected-tag { + padding: 1px 3px; + font-size: .930125rem; + margin: 2px; +} + +.tags-search.tags-search { + padding: 3px 3px; + margin: 0; +} + +.tags-input.tags-input { + width: auto !important; + display: none; // to disable adding tags to search term +} + +.tags-suggestions.tags-suggestions { + width: 10rem; +} diff --git a/src/components/ModulesSearch/ModulesSearch.tsx b/src/components/ModulesSearch/ModulesSearch.tsx new file mode 100644 index 00000000..f1dc5854 --- /dev/null +++ b/src/components/ModulesSearch/ModulesSearch.tsx @@ -0,0 +1,304 @@ +import {faSlidersH, faSearch} from '@fortawesome/free-solid-svg-icons'; +import React, {useEffect, useRef, useState} from 'react'; +import {Tag} from 'react-tag-autocomplete'; +import styles from './ModulesSearch.module.scss'; +import {Form, Row, Col, Button, Container} from 'react-bootstrap'; +import ToolTip from '../util/ToolTip'; +import {getTags, searchEditorsModules, searchModules, + searchOptionsEditorsModules, searchOptionsModules, + searchOptionsUserModules, searchUserModules +} from '../../api'; +import {TagEditor} from '../TagEditor/TagEditor'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {Formik} from 'formik'; +import {SearchOptions} from 'types/SearchOptions'; +import {makeSearchInputs} from 'factories'; +import {Module, ModuleDifficulty} from 'types/Module'; +import {UserModule} from 'types/UserModule'; +import {getModuleDifficultyLabel, moduleDifficultyOptions} from '../../util'; +import {connect} from 'react-redux'; +import {WebState} from 'redux/types/WebState'; +import {isAdmin, isAuthenticated, isCreator} from 'redux/selectors/entities'; +import {RoutePaths} from 'router/RoutePaths'; +import Select from 'react-select'; + +export type Modules = Module[] | UserModule[]; + +interface ModulesSearchProps extends ReturnType { + loadModules(): void; + showSearchedModules(modules: Modules): void; +} + +const ModulesSearch = (props: ModulesSearchProps) => { + + const initialState = makeSearchInputs(); + + const [searchInputs, setSearchInputs] = useState(initialState); + const [searchTerm, setSearchTerm] = useState(''); + const [searchTermTags, setSearchTermTags] = React.useState([]); + const [searchOptionsOn, setSearchOptionsOn] = useState(false); + const [tagSuggestions, setTagSuggestions] = React.useState([]); + + const focusRef = useRef(); + + const showSearchedModules = async (searchValue: string) => { + if (searchValue.length === 0) + props.loadModules(); + + let modules: Modules = []; + if ( (props.admin || props.creator) && window.location.pathname === RoutePaths.contentCreator ) { + modules = await searchEditorsModules(searchValue); + } + else if (props.authenticated && window.location.pathname === RoutePaths.myModules) { + modules = await searchUserModules(searchValue); + } + else { + modules = await searchModules(searchValue); + } + props.showSearchedModules(modules); + }; + + const showSearchedOptionsModules = async (searchParams: SearchOptions) => { + let modules: Modules = []; + if ( (props.admin || props.creator) && window.location.pathname === RoutePaths.contentCreator ) { + modules = await searchOptionsEditorsModules(searchParams); + } + else if (props.authenticated && window.location.pathname === RoutePaths.myModules) { + modules = await searchOptionsUserModules(searchParams); + } + else { + modules = await searchOptionsModules(searchParams); + } + props.showSearchedModules(modules); + }; + + const onSearchTermChange = (e: any) => { + const searchValue = e.target.value; + setSearchTerm(searchValue); + + if (!searchOptionsOn) { + showSearchedModules(searchValue); + } + }; + + const onTitleChange = (e: any) => { + const title = e.target.value; + setSearchInputs({ ...searchInputs, title: title }); + }; + + const onDescriptionChange = (e: any) => { + const desc = e.target.value; + setSearchInputs({ ...searchInputs, description: desc }); + }; + + const onDifficultyChange = (option: any) => { + const diff = option.value as ModuleDifficulty; + setSearchInputs({ ...searchInputs, difficulty: diff }); + }; + + const onTagAdd = (newTag: Tag) => { + setSearchTermTags(searchTermTags.concat(newTag)); + setSearchInputs({ ...searchInputs, tags: [...searchInputs.tags, newTag] }); + }; + + const onTagDelete = (idx: number) => { + const tagTBD = searchInputs.tags[idx]; + setSearchTermTags(searchTermTags.filter(tag => tag.name !== tagTBD.name)); + setSearchInputs({ ...searchInputs, tags: searchInputs.tags.filter(tag => tag.name !== tagTBD.name) }); + }; + + const onSearchTermTagDelete = (idx: number) => { + const tagTBD = searchTermTags[idx]; + + if (tagTBD.id === 'title') { + setSearchInputs({ ...searchInputs, title: '' }); + } else if (tagTBD.id === 'desc') { + setSearchInputs({ ...searchInputs, description: '' }); + } else if (tagTBD.id === 'diff') { + setSearchInputs({ ...searchInputs, difficulty: 0 }); + } else { + setSearchInputs({ ...searchInputs, tags: searchInputs.tags.filter(tag => tag.name !== tagTBD.name) }); + } + }; + + async function handleTagSuggestions(input: string) { + setTagSuggestions(await getTags(input)); + } + + const onSearchOptionsOn = () => { + setSearchOptionsOn(!searchOptionsOn); + }; + + const onBlur = (e: any) => { + if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget)) { + setSearchOptionsOn(false); + } + }; + + const onReset = () => { + setSearchInputs(initialState); + props.loadModules(); + }; + + const onSubmit = () => { + showSearchedOptionsModules(searchInputs); + }; + + useEffect(() => { + function updateSearchTermTags() { + let tags: Tag[] = []; + if (searchInputs.title.length !== 0) { + tags.push({ id: 'title', name: `Title: ${searchInputs.title.length > 10 ? + searchInputs.title.substring(0, 10) + '...' : searchInputs.title}` }); + } + if (searchInputs.description.length !== 0) { + tags.push({ id: 'desc', name: `Description: ${searchInputs.description.length > 15 ? + searchInputs.description.substring(0, 15) + '...' : searchInputs.description}` }); + } + if (searchInputs.difficulty !== 0) { + tags.push({ id: 'diff', name: `Difficulty: ${getModuleDifficultyLabel(searchInputs.difficulty)}` }); + } + tags = tags.concat(searchInputs.tags); + + setSearchTermTags(tags); + } + updateSearchTermTags(); + }, [searchInputs]); + + return ( + + {({ handleSubmit, handleReset }) => ( +
focusRef.current = ref} + onBlur={onBlur} + tabIndex={-1} + onReset={handleReset} + onSubmit={handleSubmit} + > + + + + + + + + {searchOptionsOn ? + {return;}} // should not directly add tags to search term + onDelete={onSearchTermTagDelete} + editing={true} + classNames={{ + root: styles['tags-no-border'], + rootFocused: styles['tags-focused'], + selectedTag: styles['selected-tag'], + search: styles['tags-search'], + suggestions: styles['tags-suggestions'], + searchInput: styles['tags-input'] + }} + /> : + + } + + + + + + + + + { searchOptionsOn && + + + Title + + + + Description + + + + Difficulty + ('description')} placeholder='Description' type='textarea' disabled={!editing}/> diff --git a/src/pages/ModuleEditor/ModuleEditorSchema.ts b/src/pages/ModuleEditor/ModuleEditorSchema.ts index cb041245..adabdeb6 100644 --- a/src/pages/ModuleEditor/ModuleEditorSchema.ts +++ b/src/pages/ModuleEditor/ModuleEditorSchema.ts @@ -1,6 +1,6 @@ -import {array, boolean, number, object, string, StringSchema} from 'yup'; +import {array, boolean, number, object, NumberSchema, string, StringSchema} from 'yup'; import {ModuleForm} from '../../types/editorTypes'; -import {ModuleType} from '../../types/Module'; +import {ModuleDifficulty, ModuleType} from '../../types/Module'; export const ModuleEditorSchema = object({ @@ -14,5 +14,6 @@ export const ModuleEditorSchema = object({ type: string() as StringSchema, published: boolean(), labs: array(), - moduleTags: array() + moduleTags: array(), + difficulty: number() as NumberSchema }); diff --git a/src/pages/ModulesEditor/ModulesEditor.tsx b/src/pages/ModulesEditor/ModulesEditor.tsx index 9e03f995..60f52dd0 100644 --- a/src/pages/ModulesEditor/ModulesEditor.tsx +++ b/src/pages/ModulesEditor/ModulesEditor.tsx @@ -4,11 +4,13 @@ import {getEditorsModules} from '../../api'; import {HorizontallyCenteredSpinner} from '../../components/util/HorizonallyCenteredSpinner'; import {Message} from '../../util/Message'; import {PageTitle} from '../../components/util/PageTitle'; -import {CardColumns, Col, Row} from 'react-bootstrap'; +import {Col, Row} from 'react-bootstrap'; import {UserModule} from '../../types/UserModule'; import {CreatorsModuleCard} from '../../components/CreatorsModuleCard/CreatorsModuleCard'; import {RoutePaths} from '../../router/RoutePaths'; import {ButtonLink} from '../../components/util/ButtonLink'; +import ModulesFilter from 'components/ModulesSearch/ModulesFilter'; +import ModulesSearch from 'components/ModulesSearch/ModulesSearch'; interface ModulesState { modules: UserModule[]; @@ -26,17 +28,25 @@ class ModulesEditor extends React.Component<{}, ModulesState> { this.loadModules(); } - async loadModules() { + loadModules = async () => { try { const modules = await getEditorsModules(); this.setState({modules: modules, state: 'success'}); } catch (_) { this.setState({state: 'error'}); } + }; + + updateUserModules = (modules: UserModule[]) => { + this.setState({modules: modules, state: 'success'}); } render() { - const cards = this.state.modules.map((m, i) => ); + const cards = this.state.modules.map((m, i) => + + + + ); return ( {this.state.state === 'loading' ? : @@ -53,9 +63,15 @@ class ModulesEditor extends React.Component<{}, ModulesState> {
+ + + + + + {cards.length === 0 ? -

You currently have no modules

: - {cards} +

No modules published at this time, please come back later.

: + {cards} } } diff --git a/src/pages/MyModules/MyModules.tsx b/src/pages/MyModules/MyModules.tsx index 4e945392..199d1ff0 100644 --- a/src/pages/MyModules/MyModules.tsx +++ b/src/pages/MyModules/MyModules.tsx @@ -5,6 +5,9 @@ import {getUserModules} from '../../api'; import {Layout} from '../Layout/Layout'; import {RoutePaths} from '../../router/RoutePaths'; import {UserModule} from '../../types/UserModule'; +import ModulesSearch from 'components/ModulesSearch/ModulesSearch'; +import {PageTitle} from 'components/util/PageTitle'; +import ModulesFilter from 'components/ModulesSearch/ModulesFilter'; interface MyModulesState { modules: UserModule[]; @@ -21,10 +24,10 @@ class MyModules extends React.Component<{}, MyModulesState> { this.loadModules(); } - async loadModules() { + loadModules = async () => { const modules = await getUserModules(); - this.setState({ modules: modules}); - } + this.setState({modules: modules}); + }; renderNoModules() { return ( @@ -35,17 +38,28 @@ class MyModules extends React.Component<{}, MyModulesState> { ); } + updateModules = (modules: UserModule[]) => { + this.setState({modules: modules}); + } + render() { - const cards = this.state.modules.map((m, i) => - - - ); - return ( - -

My Modules

- {cards.length === 0 ? this.renderNoModules() : {cards}} -
- ); + const cards = this.state.modules.map((m, i) => + + + + ); + return ( + + + My Modules + + + + + + {cards.length === 0 ? this.renderNoModules() : {cards}} + + ); } } diff --git a/src/pages/PublicModule/PublicModule.tsx b/src/pages/PublicModule/PublicModule.tsx index e580b947..d526631a 100644 --- a/src/pages/PublicModule/PublicModule.tsx +++ b/src/pages/PublicModule/PublicModule.tsx @@ -33,7 +33,8 @@ class PublicModule extends Component { userId: 0, specialCode: '', type: 'SingleUser', - updatedAt: '' + updatedAt: '', + difficulty: 0 }, startingModule: false }; diff --git a/src/pages/UserModulePage/UserModulePage.tsx b/src/pages/UserModulePage/UserModulePage.tsx index bdacf139..0d8d19ab 100644 --- a/src/pages/UserModulePage/UserModulePage.tsx +++ b/src/pages/UserModulePage/UserModulePage.tsx @@ -40,7 +40,8 @@ class UserModulePage extends Component !!val).join(' '); @@ -289,5 +290,16 @@ export function convertArrayToDictionary(arr: T[], key: keyof T): {[key: stri return obj; } +export const moduleDifficultyOptions = [ + {value: 0, label: 'Any'}, + {value: 1, label: 'Easy'}, + {value: 2, label: 'Medium'}, + {value: 3, label: 'Hard'} +]; + +export function getModuleDifficultyLabel(val: ModuleDifficulty) { + return moduleDifficultyOptions[val].label; +} + export const cast = (value: T): T => value; diff --git a/tsconfig.json b/tsconfig.json index 15587477..8ffb1164 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "baseUrl": "src", "outDir": "build/dist", "module": "esnext", - "target": "es5", + "target": "es6", "lib": [ "es6", "dom"