diff --git a/README.md b/README.md index b265f604..37a1c1b1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Improved Go Playground powered by Monaco Editor and React * 💡 Code autocomplete * 💾 Load and save files -* 🛠 [WebAssembly](https://github.com/golang/go/wiki/WebAssembly) support (see [latest release notes](https://github.com/x1unix/go-playground/releases/tag/v1.3.0)) +* 📔 Snippets and tutorials +* 🛠 [WebAssembly](https://github.com/golang/go/wiki/WebAssembly) support * 🌚 Dark theme @@ -19,7 +20,7 @@ And more ## Demo -[http://goplay.x1unix.com/](http://goplay.x1unix.com/) +[http://goplay.tools/](goplay.tools) ## Installation @@ -40,6 +41,13 @@ $ ./playground -f ./data/packages.json -debug Use `-help` to get information about command params +### Third-party credits + +* Default playground run server provided by [play.golang.org](https://play.golang.org) +* Code for templates and tutorials provided by [gobyexample.com](https://gobyexample.com/) +* Code completion snippets were inspired by [tj/vscode-snippets](https://github.com/tj/vscode-snippets/blob/master/go.json) + + ## Contributors ### Code Contributors diff --git a/build/Dockerfile b/build/Dockerfile index 8904cd76..58a2b44b 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -7,17 +7,17 @@ ARG GITHUB_URL=https://github.com/x1unix/go-playground RUN yarn install --silent && \ REACT_APP_VERSION=$APP_VERSION REACT_APP_GITHUB_URL=$GITHUB_URL REACT_APP_GTAG=$APP_GTAG yarn build -FROM golang:1.13-alpine as build +FROM golang:1.14-alpine as build WORKDIR /tmp/playground COPY cmd ./cmd COPY pkg ./pkg COPY go.mod . COPY go.sum . -RUN go build -o server ./cmd/playground && \ +RUN go build -o server -ldflags="-X 'main.Version=$APP_VERSION'" ./cmd/playground && \ GOOS=js GOARCH=wasm go build -o ./worker.wasm ./cmd/webworker && \ cp $(go env GOROOT)/misc/wasm/wasm_exec.js . -FROM golang:1.13-alpine as production +FROM golang:1.14-alpine as production WORKDIR /opt/playground ENV GOROOT /usr/local/go ENV APP_CLEAN_INTERVAL=10m diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 6e23c8f3..e134f81c 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -18,6 +18,8 @@ import ( "go.uber.org/zap" ) +const Version = "testing" + type appArgs struct { packagesFile string addr string @@ -75,6 +77,7 @@ func start(goRoot string, args appArgs) error { return fmt.Errorf("invalid cleanup interval parameter: %s", err) } + zap.S().Info("Server version: ", Version) zap.S().Infof("GOROOT is %q", goRoot) zap.S().Infof("Packages file is %q", args.packagesFile) zap.S().Infof("Cleanup interval is %s", cleanInterval.String()) @@ -94,7 +97,7 @@ func start(goRoot string, args appArgs) error { go store.StartCleaner(ctx, cleanInterval, nil) r := mux.NewRouter() - langserver.New(packages, compiler.NewBuildService(zap.S(), store)). + langserver.New(Version, packages, compiler.NewBuildService(zap.S(), store)). Mount(r.PathPrefix("/api").Subrouter()) r.PathPrefix("/").Handler(langserver.SpaFileServer("./public")) diff --git a/pkg/analyzer/package.go b/pkg/analyzer/package.go index 08684bdb..458345b9 100644 --- a/pkg/analyzer/package.go +++ b/pkg/analyzer/package.go @@ -50,6 +50,13 @@ func (p *Package) SymbolByChar(chr string) []*CompletionItem { return append(p.Functions.Match(chr), result...) } +func (p *Package) AllSymbols() []*CompletionItem { + out := make([]*CompletionItem, 0, p.Values.Len()+p.Functions.Len()) + out = append(out, p.Functions.Symbols...) + out = append(out, p.Values.Symbols...) + return out +} + func (p *Package) GetCompletionItem() *CompletionItem { return &CompletionItem{ Label: p.Name, diff --git a/pkg/analyzer/sym_index.go b/pkg/analyzer/sym_index.go index a3c0ea84..865d6d42 100644 --- a/pkg/analyzer/sym_index.go +++ b/pkg/analyzer/sym_index.go @@ -6,6 +6,10 @@ type SymbolIndex struct { charMap map[string][]*CompletionItem } +func (si *SymbolIndex) Len() int { + return len(si.Symbols) +} + func emptySymbolIndex() SymbolIndex { return SymbolIndex{ Symbols: []*CompletionItem{}, diff --git a/pkg/langserver/request.go b/pkg/langserver/request.go index 8377c940..446b0b49 100644 --- a/pkg/langserver/request.go +++ b/pkg/langserver/request.go @@ -52,6 +52,14 @@ func (r *ErrorResponse) Write(w http.ResponseWriter) http.ResponseWriter { return w } +type VersionResponse struct { + Version string `json:"version"` +} + +func (r VersionResponse) Write(w http.ResponseWriter) { + WriteJSON(w, r) +} + type SuggestionsResponse struct { Suggestions []*analyzer.CompletionItem `json:"suggestions"` } diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index f6827d74..f8f402e8 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -29,15 +29,17 @@ const ( ) type Service struct { + version string log *zap.SugaredLogger index analyzer.PackageIndex compiler compiler.BuildService limiter *rate.Limiter } -func New(packages []*analyzer.Package, builder compiler.BuildService) *Service { +func New(version string, packages []*analyzer.Package, builder compiler.BuildService) *Service { return &Service{ compiler: builder, + version: version, log: zap.S().Named("langserver"), index: analyzer.BuildPackageIndex(packages), limiter: rate.NewLimiter(rate.Every(frameTime), compileRequestsPerFrame), @@ -46,6 +48,8 @@ func New(packages []*analyzer.Package, builder compiler.BuildService) *Service { // Mount mounts service on route func (s *Service) Mount(r *mux.Router) { + r.Path("/version"). + HandlerFunc(WrapHandler(s.HandleGetVersion)) r.Path("/suggest"). HandlerFunc(WrapHandler(s.HandleGetSuggestion)) r.Path("/run").Methods(http.MethodPost). @@ -83,10 +87,6 @@ func (s *Service) lookupBuiltin(val string) (*SuggestionsResponse, error) { } func (s *Service) provideSuggestion(req SuggestionRequest) (*SuggestionsResponse, error) { - if req.Value == "" { - return nil, fmt.Errorf("empty suggestion request value, nothing to provide") - } - // Provide package suggestions (if requested) if req.PackageName != "" { pkg, ok := s.index.PackageByName(req.PackageName) @@ -98,14 +98,30 @@ func (s *Service) provideSuggestion(req SuggestionRequest) (*SuggestionsResponse return nil, fmt.Errorf("failed to analyze package %q: %s", req.PackageName, err) } + var symbols []*analyzer.CompletionItem + if req.Value != "" { + symbols = pkg.SymbolByChar(req.Value) + } else { + symbols = pkg.AllSymbols() + } + return &SuggestionsResponse{ - Suggestions: pkg.SymbolByChar(req.Value), + Suggestions: symbols, }, nil } + if req.Value == "" { + return nil, fmt.Errorf("empty suggestion request value, nothing to provide") + } + return s.lookupBuiltin(req.Value) } +func (s *Service) HandleGetVersion(w http.ResponseWriter, r *http.Request) error { + WriteJSON(w, VersionResponse{Version: s.version}) + return nil +} + func (s *Service) HandleGetSuggestion(w http.ResponseWriter, r *http.Request) error { q := r.URL.Query() value := q.Get("value") diff --git a/web/package.json b/web/package.json index d9c43b17..10d1e321 100644 --- a/web/package.json +++ b/web/package.json @@ -10,13 +10,13 @@ "circular-dependency-plugin": "^5.2.0", "connected-react-router": "^6.6.1", "file-saver": "^2.0.2", - "monaco-editor": "^0.19.3", - "monaco-editor-webpack-plugin": "^1.8.2", + "monaco-editor": "^0.20.0", + "monaco-editor-webpack-plugin": "^1.9.0", "office-ui-fabric-react": "^7.82.1", "react": "^16.12.0", "react-app-rewired": "^2.1.5", "react-dom": "^16.12.0", - "react-monaco-editor": "^0.33.0", + "react-monaco-editor": "^0.39.1", "react-redux": "^7.1.3", "react-router-dom": "^5.1.2", "react-scripts": "3.3.0", @@ -26,8 +26,8 @@ "uuid": "^3.4.0" }, "scripts": { - "start": "react-app-rewired start", - "build": "react-app-rewired build", + "start": "GENERATE_SOURCEMAP=true react-app-rewired start", + "build": "GENERATE_SOURCEMAP=false react-app-rewired build", "test": "react-app-rewired test", "eject": "react-app-rewired eject", "gen:suggestions": "node ./src/editor/internal/genpkgcache.js" diff --git a/web/public/fonts/CascadiaCode.ttf b/web/public/fonts/CascadiaCode.ttf new file mode 100644 index 00000000..a271f0dd Binary files /dev/null and b/web/public/fonts/CascadiaCode.ttf differ diff --git a/web/public/fonts/FiraCode-Bold.ttf b/web/public/fonts/FiraCode-Bold.ttf new file mode 100644 index 00000000..39265a19 Binary files /dev/null and b/web/public/fonts/FiraCode-Bold.ttf differ diff --git a/web/public/fonts/FiraCode-Medium.ttf b/web/public/fonts/FiraCode-Medium.ttf new file mode 100644 index 00000000..2cad0184 Binary files /dev/null and b/web/public/fonts/FiraCode-Medium.ttf differ diff --git a/web/public/fonts/FiraCode-Regular.ttf b/web/public/fonts/FiraCode-Regular.ttf new file mode 100644 index 00000000..0d570685 Binary files /dev/null and b/web/public/fonts/FiraCode-Regular.ttf differ diff --git a/web/public/fonts/FiraCode-Retina.ttf b/web/public/fonts/FiraCode-Retina.ttf new file mode 100644 index 00000000..248d36b6 Binary files /dev/null and b/web/public/fonts/FiraCode-Retina.ttf differ diff --git a/web/public/fonts/FiraCode-SemiBold.ttf b/web/public/fonts/FiraCode-SemiBold.ttf new file mode 100644 index 00000000..56a8016b Binary files /dev/null and b/web/public/fonts/FiraCode-SemiBold.ttf differ diff --git a/web/public/fonts/JetBrainsMono-Bold.ttf b/web/public/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 00000000..c2075fc3 Binary files /dev/null and b/web/public/fonts/JetBrainsMono-Bold.ttf differ diff --git a/web/public/fonts/JetBrainsMono-Medium.ttf b/web/public/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 00000000..1ee0df64 Binary files /dev/null and b/web/public/fonts/JetBrainsMono-Medium.ttf differ diff --git a/web/public/fonts/JetBrainsMono-Regular.ttf b/web/public/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..38b902bd Binary files /dev/null and b/web/public/fonts/JetBrainsMono-Regular.ttf differ diff --git a/web/src/ChangeLogModal.tsx b/web/src/ChangeLogModal.tsx new file mode 100644 index 00000000..96c2c214 --- /dev/null +++ b/web/src/ChangeLogModal.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Modal } from 'office-ui-fabric-react/lib/Modal'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import {getTheme, IconButton } from 'office-ui-fabric-react'; +import {getContentStyles, getIconButtonStyles} from './styles/modal'; +import config from './services/config'; + +const TITLE_ID = 'ChangeLogTitle'; +const SUB_TITLE_ID = 'ChangeLogSubtitle'; + +interface ChangeLogModalProps { + isOpen: boolean + onClose: () => void +} + +export default function ChangeLogModal(props: ChangeLogModalProps) { + const theme = getTheme(); + const contentStyles = getContentStyles(theme); + const iconButtonStyles = getIconButtonStyles(theme); + + return ( + +
+ Changelog for {config.appVersion} + +
+
+

+ Interface - Global +

+

+

+ Interface - Settings +

+

+

+ Interface - Editor +

+

+

+ And more! +

+

+ Full release notes for are available here +

+
+
+ ) +} + +ChangeLogModal.defaultProps = {isOpen: false}; \ No newline at end of file diff --git a/web/src/Header.css b/web/src/Header.css index ead8c0bd..8234eb1c 100644 --- a/web/src/Header.css +++ b/web/src/Header.css @@ -7,6 +7,21 @@ min-height: 44px; } +.app__update { + display: none; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + max-width: 500px; + z-index: 9999; +} + +.app__update.app__update--visible { + display: block +} + .header__preloader { margin-right: 15px; } diff --git a/web/src/Header.tsx b/web/src/Header.tsx index 72f53ad8..89468a37 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -1,38 +1,48 @@ import React from 'react'; import './Header.css' -import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; -import { getTheme } from '@uifabric/styling'; +import { MessageBarButton } from 'office-ui-fabric-react'; +import {CommandBar, ICommandBarItemProps} from 'office-ui-fabric-react/lib/CommandBar'; +import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar'; +import {getTheme} from '@uifabric/styling'; import SettingsModal, {SettingsChanges} from './settings/SettingsModal'; import AboutModal from './AboutModal'; +import config from './services/config'; +import api from './services/api'; +import { getSnippetsMenuItems, SnippetMenuItem } from './utils/headerutils'; import { Connect, - newImportFileDispatcher, + dispatchToggleTheme, formatFileDispatcher, + newBuildParamsChangeDispatcher, newCodeImportDispatcher, + newImportFileDispatcher, + newMonacoParamsChangeDispatcher, newSnippetLoadDispatcher, runFileDispatcher, saveFileDispatcher, - dispatchToggleTheme, - shareSnippetDispatcher, - newBuildParamsChangeDispatcher, - newMonacoParamsChangeDispatcher + shareSnippetDispatcher } from './store'; - +import ChangeLogModal from "./ChangeLogModal"; interface HeaderState { showSettings: boolean showAbout: boolean + showChangelog: boolean loading: boolean + showUpdateBanner: boolean } @Connect(s => ({darkMode: s.settings.darkMode, loading: s.status?.loading})) export class Header extends React.Component { private fileInput?: HTMLInputElement; + private snippetMenuItems = getSnippetsMenuItems(i => this.onSnippetMenuItemClick(i)); constructor(props) { super(props); this.state = { showSettings: false, showAbout: false, - loading: false + showChangelog: false, + loading: false, + showUpdateBanner: false }; } @@ -42,6 +52,13 @@ export class Header extends React.Component { fileElement.accept = '.go'; fileElement.addEventListener('change', () => this.onItemSelect(), false); this.fileInput = fileElement; + + // show update popover + api.getVersion().then(r => { + const {version} = r; + if (!version) return; + this.setState({showUpdateBanner: version !== config.appVersion}); + }).catch(err => console.warn('failed to check server API version: ', err)); } onItemSelect() { @@ -53,14 +70,23 @@ export class Header extends React.Component { this.props.dispatch(newImportFileDispatcher(file)); } + onSnippetMenuItemClick(item: SnippetMenuItem) { + const dispatcher = item.snippet ? newSnippetLoadDispatcher(item.snippet) : newCodeImportDispatcher(item.label, item.text as string); + this.props.dispatch(dispatcher); + } + get menuItems(): ICommandBarItemProps[] { return [ { key: 'openFile', text: 'Open', + split: true, iconProps: {iconName: 'OpenFile'}, disabled: this.props.loading, onClick: () => this.fileInput?.click(), + subMenuProps: { + items: this.snippetMenuItems, + }, }, { key: 'run', @@ -89,12 +115,32 @@ export class Header extends React.Component { onClick: () => { this.props.dispatch(saveFileDispatcher); }, + }, + { + key: 'settings', + text: 'Settings', + ariaLabel: 'Settings', + iconProps: {iconName: 'Settings'}, + disabled: this.props.loading, + onClick: () => { + this.setState({showSettings: true}); + } } ]; } get asideItems(): ICommandBarItemProps[] { return [ + { + key: 'changelog', + text: 'What\'s new', + ariaLabel: 'Changelog', + disabled: this.props.loading, + iconProps: {iconName: 'Giftbox'}, + onClick: () => { + this.setState({showChangelog: true}); + } + }, { key: 'format', text: 'Format', @@ -122,21 +168,23 @@ export class Header extends React.Component { get overflowItems(): ICommandBarItemProps[] { return [ { - key: 'settings', - text: 'Settings', - ariaLabel: 'Settings', - iconOnly: true, - iconProps: {iconName: 'Settings'}, - disabled: this.props.loading, - onClick: () => { - this.setState({showSettings: true}); - } + key: 'new-issue', + text: 'Submit Issue', + ariaLabel: 'Submit Issue', + iconProps: {iconName: 'Bug'}, + onClick: () => window.open(config.issueUrl, '_blank') + }, + { + key: 'donate', + text: 'Donate', + ariaLabel: 'Donate', + iconProps: {iconName: 'Heart'}, + onClick: () => window.open(config.donateUrl, '_blank') }, { key: 'about', text: 'About', ariaLabel: 'About', - iconOnly: true, iconProps: {iconName: 'Info'}, onClick: () => { this.setState({showAbout: true}); @@ -170,6 +218,23 @@ export class Header extends React.Component { render() { return
+ this.setState({showUpdateBanner: false})} + dismissButtonAriaLabel="Close" + isMultiline={false} + actions={ +
+ { + this.setState({showUpdateBanner: false}); + config.forceRefreshPage(); + }}>Action +
+ } + > + Web application was updated, click Reload to apply changes +
{ /> this.onSettingsClose(args)} isOpen={this.state.showSettings} /> this.setState({showAbout: false})} isOpen={this.state.showAbout} /> + this.setState({showChangelog: false})} isOpen={this.state.showChangelog} />
; } } diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index 2b8930b9..fe2c59ab 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -1,14 +1,13 @@ import React from 'react'; import './Preview.css'; -import {EDITOR_FONTS} from './editor/props'; +import {getDefaultFontFamily} from './services/fonts'; import {Connect} from './store'; import { RuntimeType } from './services/config'; import {EvalEvent} from './services/api'; import EvalEventView from './EvalEventView'; import {getTheme} from '@uifabric/styling'; import {MessageBar, MessageBarType} from 'office-ui-fabric-react'; -import {ProgressIndicator} from "office-ui-fabric-react/lib/ProgressIndicator"; - +import {ProgressIndicator} from 'office-ui-fabric-react/lib/ProgressIndicator'; interface PreviewProps { lastError?:string | null; @@ -24,7 +23,7 @@ export default class Preview extends React.Component { return { backgroundColor: palette.neutralLight, color: palette.neutralDark, - fontFamily: EDITOR_FONTS + fontFamily: getDefaultFontFamily(), } } diff --git a/web/src/editor/props.ts b/web/src/editor/props.ts index 9be17e80..b0107a88 100644 --- a/web/src/editor/props.ts +++ b/web/src/editor/props.ts @@ -1,5 +1,6 @@ import * as monaco from 'monaco-editor'; -import {MonacoSettings} from "../services/config"; +import {MonacoSettings} from '../services/config'; +import { getFontFamily, getDefaultFontFamily } from '../services/fonts'; export const LANGUAGE_GOLANG = 'go'; @@ -13,24 +14,20 @@ export const DEMO_CODE = [ '}\n' ].join('\n'); -export const EDITOR_FONTS = [ - 'Fira Code', - 'Menlo', - 'Monaco', - 'Consolas', - 'Lucida Console', - 'Roboto Mono', - 'Droid Sans', - 'Courier New', - 'monospace' -].join(', '); - // stateToOptions converts MonacoState to IEditorOptions export const stateToOptions = (state: MonacoSettings): monaco.editor.IEditorOptions => { - const {selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, contextMenu } = state; + const { + selectOnLineNumbers, + mouseWheelZoom, + smoothScrolling, + cursorBlinking, + fontLigatures, + cursorStyle, + contextMenu + } = state; return { - selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, - fontFamily: EDITOR_FONTS, + selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, fontLigatures, + fontFamily: state.fontFamily ? getFontFamily(state.fontFamily) : getDefaultFontFamily(), showUnused: true, automaticLayout: true, minimap: {enabled: state.minimap}, diff --git a/web/src/editor/provider.ts b/web/src/editor/provider.ts index 6c59e81e..a7065a13 100644 --- a/web/src/editor/provider.ts +++ b/web/src/editor/provider.ts @@ -1,5 +1,6 @@ import * as monaco from 'monaco-editor'; import {IAPIClient} from '../services/api'; +import snippets from './snippets' // Import aliases type CompletionList = monaco.languages.CompletionList; @@ -53,10 +54,11 @@ class GoCompletionItemProvider implements monaco.languages.CompletionItemProvide try { const resp = await this.client.getSuggestions(query); - return Promise.resolve(resp); + resp.suggestions = resp.suggestions ? snippets.concat(resp.suggestions) : snippets; + return resp; } catch (err) { console.error(`Failed to get code completion from server: ${err.message}`); - return Promise.resolve({suggestions: []}); + return {suggestions: snippets}; } } } diff --git a/web/src/editor/snippets.ts b/web/src/editor/snippets.ts new file mode 100644 index 00000000..7c7ee405 --- /dev/null +++ b/web/src/editor/snippets.ts @@ -0,0 +1,128 @@ +import * as monaco from 'monaco-editor'; + +/* eslint-disable no-template-curly-in-string */ + +// Import aliases +type CompletionList = monaco.languages.CompletionList; +type CompletionContext = monaco.languages.CompletionContext; +type ITextModel = monaco.editor.ITextModel; +type Position = monaco.Position; + +/** + * List of snippets for editor + */ +const snippets = [ + { + label: 'iferr', + insertText: [ + 'if err != nil {', + '\treturn ${1:nil, err}', + '}' + ].join('\n'), + documentation: 'Return error if err != nil' + }, + { + label: 'switch', + insertText: [ + "switch ${1:expression} {", + "\tcase ${2:match}:", + "\t\t$0", + "\tdefault:", + "\t\t// TODO: implement", + "}", + ].join('\n'), + documentation: 'Insert switch statement' + }, + { + label: 'go', + insertText: [ + "go func(){", + "\t$0", + "}()" + ].join('\n'), + documentation: 'Call goroutine' + }, + { + label: 'append', + insertText: "$1 = append($1, $0)", + documentation: 'Append to slice' + }, + { + label: 'typestruct', + insertText: [ + "type ${1:name} struct {", + "\t$0", + "}" + ].join('\n'), + documentation: 'Declare a struct' + }, + { + label: 'typeinterface', + insertText: [ + "type ${1:name} interface {", + "\t$0", + "}" + ].join('\n'), + documentation: 'Declare a struct' + }, + { + label: 'fmtprintf', + insertText: 'fmt.Printf("${1:format}\n", $0)', + documentation: 'fmt.Printf() shorthand' + }, + { + label: 'fmtprintln', + insertText: 'fmt.Println(${0:message})', + documentation: 'fmt.Println() shorthand' + }, + { + label: 'returnnil', + insertText: 'return nil', + documentation: 'return nil' + }, + { + label: 'timenow', + insertText: '${0:t} := time.Now()', + documentation: 'time.Now() shorthand' + }, + { + label: 'makeslice', + insertText: '${0:items} := make([]${1:string}, ${2:0}, ${3:0})', + documentation: 'Make a new slice' + }, + { + label: 'slice', + insertText: '${1:items} := []${2:string}{${0}}', + documentation: 'Declare slice' + }, + { + label: 'map', + insertText: 'map[${1:string}]${0:value}', + documentation: 'Insert map type' + }, + { + label: 'func', + insertText: [ + "func ${1:functionName}(${3:param}) $4 {", + " $0", + "}" + ].join('\n'), + documentation: 'Insert a function' + }, + { + label: 'for', + insertText: [ + "for _, ${1:v} := range ${2:value} {", + " $0", + "}" + ].join('\n'), + documentation: 'Insert a for range statement' + }, + +].map(s => ({ + kind: monaco.languages.CompletionItemKind.Snippet, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + ...s, +})); + +export default snippets as monaco.languages.CompletionItem[]; diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 5795ff4e..6a8d47df 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -36,9 +36,15 @@ export interface BuildResponse { fileName: string } +export interface VersionResponse { + version: string +} + export interface IAPIClient { readonly axiosClient: AxiosInstance + getVersion(): Promise + getSuggestions(query: { packageName?: string, value?: string }): Promise evaluateCode(code: string, format: boolean): Promise @@ -71,6 +77,10 @@ class Client implements IAPIClient { constructor(private client: axios.AxiosInstance) { } + async getVersion(): Promise { + return this.get(`/version?=${Date.now()}`) + } + async getSuggestions(query: { packageName?: string, value?: string }): Promise { const queryParams = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); return this.get(`/suggest?${queryParams}`); diff --git a/web/src/services/config.ts b/web/src/services/config.ts index 96c6da94..b0eaae7a 100644 --- a/web/src/services/config.ts +++ b/web/src/services/config.ts @@ -1,17 +1,21 @@ import {loadTheme} from '@uifabric/styling'; -import {DarkTheme, LightTheme} from "./colors"; +import { DEFAULT_FONT } from './fonts'; +import {DarkTheme, LightTheme} from './colors'; const DARK_THEME_KEY = 'ui.darkTheme.enabled'; const RUNTIME_TYPE_KEY = 'go.build.runtime'; const AUTOFORMAT_KEY = 'go.build.autoFormat'; const MONACO_SETTINGS = 'ms.monaco.settings'; + export enum RuntimeType { GoPlayground = 'GO_PLAYGROUND', WebAssembly = 'WASM' } export interface MonacoSettings { + fontFamily: string, + fontLigatures: boolean, cursorBlinking: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid', cursorStyle: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin', selectOnLineNumbers: boolean, @@ -22,6 +26,8 @@ export interface MonacoSettings { } const defaultMonacoSettings: MonacoSettings = { + fontFamily: DEFAULT_FONT, + fontLigatures: false, cursorBlinking: 'blink', cursorStyle: 'line', selectOnLineNumbers: true, @@ -43,6 +49,8 @@ export default { appVersion: getVariableValue('VERSION', '1.0.0'), serverUrl: getVariableValue('LANG_SERVER', window.location.origin), githubUrl: getVariableValue('GITHUB_URL', 'https://github.com/x1unix/go-playground'), + issueUrl: getVariableValue('ISSUE_URL', 'https://github.com/x1unix/go-playground/issues/new'), + donateUrl: getVariableValue('DONATE_URL', 'https://opencollective.com/bttr-go-playground'), get darkThemeEnabled(): boolean { if (this._cache[DARK_THEME_KEY]) { @@ -131,5 +139,9 @@ export default { sync() { setThemeStyles(this.darkThemeEnabled); + }, + + forceRefreshPage() { + document.location.reload(true); } }; \ No newline at end of file diff --git a/web/src/services/fonts.ts b/web/src/services/fonts.ts new file mode 100644 index 00000000..92ffa864 --- /dev/null +++ b/web/src/services/fonts.ts @@ -0,0 +1,150 @@ +///////////////////////////////////////// +// Custom monaco editor fonts manager // +///////////////////////////////////////// + +const loadedFonts = new Set(); + +/** + * Default system monospace font + */ +export const DEFAULT_FONT = 'default'; + +export const fallbackFonts = [ + 'Menlo', + 'Monaco', + 'Consolas', + `"Lucida Console"`, + `"Roboto Mono"`, + `"Courier New"`, + 'monospace' +].join(','); + +interface FontDeclaration { + label: string; + family: string; + fonts: { + src: {url: string, format?: 'opentype' | 'embedded-opentype' | 'woff' | 'truetype' | 'svg' }[], + weight?: 'normal' | 'bold' | 'light' | number, + style?: 'normal' | 'italic' + }[]; +} + +/** + * List of available fonts + */ +const fontsList: {[label: string]: FontDeclaration} = { + 'CascadiaCode': { + label: 'Cascadia Code', + family: 'CascadiaCode', + fonts: [ + { + src: [{url: '/fonts/CascadiaCode.ttf', format: 'truetype'}] + } + ] + }, + 'FiraCode': { + label: 'Fira Code', + family: 'FiraCode', + fonts: [ + { + src: [{url: '/fonts/FiraCode-Retina.ttf', format: 'truetype'}], + weight: 400, + }, + { + src: [{url: '/fonts/FiraCode-Medium.ttf', format: 'truetype'}], + weight: 500, + }, + { + src: [{url: '/fonts/FiraCode-SemiBold.ttf', format: 'truetype'}], + weight: 600, + }, + { + src: [{url: '/fonts/FiraCode-Bold.ttf', format: 'truetype'}], + weight: 700, + } + ] + }, + 'JetBrains-Mono': { + label: 'JetBrains Mono', + family: 'JetBrains-Mono', + fonts: [ + { + src: [{url: '/fonts/JetBrainsMono-Regular.ttf', format: 'truetype'}], + weight: 400, + }, + { + src: [{url: '/fonts/JetBrainsMono-Medium.ttf', format: 'truetype'}], + weight: 500, + }, + { + src: [{url: '/fonts/JetBrainsMono-Bold.ttf', format: 'truetype'}], + weight: 700, + } + ] + }, +} + +/** + * Returns default monospace font style + */ +export const getDefaultFontFamily = () => fallbackFonts; + +/** + * Loads additional font on page + * @param fontName font name + */ +export function loadFont(fontName: string) { + let font = fontsList[fontName]; + if (!font || loadedFonts.has(fontName)) return; + console.log('Loading font "%s"...', font.label); + let elem = document.createElement('style'); + elem.id = `font-${fontName}`; + elem.innerText = fontToStyle(font); + document.head.appendChild(elem); + loadedFonts.add(fontName); +} + +/** + * Loads font and returns font-family value for font in CSS + * + * If font is not known, default system monospace font returned. + * @param fontName font name + */ +export function getFontFamily(fontName: string): string { + if (fontName === DEFAULT_FONT) return fallbackFonts; + let font = fontsList[fontName]; + if (!font) { + console.warn('getFontFamily: unknown font "%s", fallback monospace font used', fontName); + return fallbackFonts; + } + + loadFont(fontName); + return `${font.family},${fallbackFonts}`; +} + +/** + * Returns list of available additional fonts + */ +export const getAvailableFonts = () => Object.keys(fontsList).map(family => ({ + family, label: fontsList[family].label +})) + +function fontToStyle(decl: FontDeclaration): string { + const { family, fonts } = decl; + return fonts.map(f => `@font-face { + font-family: "${family}"; + src: ${f.src.map(src => fontSourceToCSS(src.url, src.format)).join(', ')}; + ${fontStyleToCSS(f.weight, f.style)} + }`).join('\n'); +} + +const fontStyleToCSS = (weight?: string|number, style?: string) => { + if (!weight && !style) return; + let out: string[] = []; + if (weight) out.push(`font-weight: ${weight}`); + if (style) out.push(`font-style: ${style}`); + return out.join('; '); +}; + +const fontSourceToCSS = (url: string, format?: string) => + format?.length ? `url("${url}") format("${format}")` : `url("${url}")`; \ No newline at end of file diff --git a/web/src/services/go/snippets.json b/web/src/services/go/snippets.json new file mode 100644 index 00000000..9c62323b --- /dev/null +++ b/web/src/services/go/snippets.json @@ -0,0 +1,169 @@ +{ + "Templates": [ + { + "label": "New Empty Test", + "type": "test", + "text": "package main\n\nimport \"testing\"\n\nfunc TestExample(t *testing.T) {\n\tt.Log(\"this is unit test sample\")\n}\n" + }, + { + "label": "Example Test", + "type": "test", + "snippet": "GFuPdlBlyMU" + }, + { + "label": "Hello World", + "snippet": "NeviD0awXjt" + } + ], + "Basic Examples": [ + { + "label": "Hello World", + "snippet": "NeviD0awXjt" + }, + { + "label": "Maps", + "snippet": "agK2Ro2i-Lu" + }, + { + "label": "Sorting", + "snippet": "_gY0tANzJ4l" + }, + { + "label": "Custom sort", + "snippet": "h4g4vaLBtkw" + }, + { + "label": "Recursion", + "snippet": "smWim1q9ofu" + }, + { + "label": "Structs & methods", + "snippet": "4wmDCAydC1e" + }, + { + "label": "Interfaces", + "snippet": "XJASG4MxBQr" + }, + { + "label": "Errors", + "snippet": "NiJOpCPO3L0" + } + ], + "Concurrency": [ + { + "label": "Goroutines", + "snippet": "I7scqRijEJt" + }, + { + "label": "Channels", + "snippet": "MaLY7AiAkHM" + }, + { + "label": "Buffered channels", + "snippet": "3BRCdRnRszb" + }, + { + "label": "Select", + "snippet": "FzONhs4-tae" + }, + { + "label": "Timeouts", + "snippet": "4oOz0j29MJ6" + }, + { + "label": "Non-blocking channel operations", + "snippet": "TFv6-7OVNVq" + }, + { + "label": "Closing channels", + "snippet": "yQMclmwOYs9" + }, + { + "label": "Timers", + "snippet": "gF7VLRz3URM" + }, + { + "label": "Tickers", + "snippet": "gs6zoJP-Pl9" + }, + { + "label": "Worker pools", + "snippet": "hiSJJsYZJKL" + }, + { + "label": "Rate limit", + "snippet": "20c_m1AtOEI" + }, + { + "label": "Atomics", + "snippet": "j-14agntvEO" + }, + { + "label": "Mutexes", + "snippet": "0WEmOOjoCjp" + }, + { + "label": "Mutexes", + "snippet": "0WEmOOjoCjp" + }, + { + "label": "Stateful goroutines", + "snippet": "5mf_P9xqBzk" + }, + { + "label": "Context", + "snippet": "0_bu1o8rIBO" + } + ], + "Other examples": [ + { + "label": "Random number", + "snippet": "PGklfJzErTN" + }, + { + "label": "Number parsing", + "snippet": "ZAMEid6Fpmu" + }, + { + "label": "Regular expressions", + "snippet": "LEKGY_d3Nu_P" + }, + { + "label": "JSON", + "snippet": "JOQpRGJWAxR" + }, + { + "label": "HTTP Client", + "snippet": "kHCcVLoz7nd" + }, + { + "label": "Time", + "snippet": "YAM3s1KPc8c" + }, + { + "label": "Time formatting", + "snippet": "BoZYtr_2j66" + }, + { + "label": "SHA1 hash", + "snippet": "XLftf8Gvj4y" + }, + { + "label": "Base64 encoding", + "snippet": "S7ff3UgzNlG" + }, + { + "label": "Writing files", + "snippet": "fQ7sd4gXv0F" + }, + { + "label": "Reading files", + "snippet": "kF0cDC0drsX" + }, + { + "label": "Signals", + "snippet": "YRV64KEXJW1" + } + ] + +} diff --git a/web/src/services/go/snippets.ts b/web/src/services/go/snippets.ts new file mode 100644 index 00000000..8141218b --- /dev/null +++ b/web/src/services/go/snippets.ts @@ -0,0 +1,33 @@ +import snippets from './snippets.json'; + +export enum SnippetType { + Test = 'test' +} + +export interface Snippet { + /** + * label is snippet label + */ + label: string + + /** + * SnippetType is snippet type (if it's a test, etc) + */ + type?: SnippetType + + /** + * Snippet is snipped ID to be loaded + * + * Presents if snippet is referenced to snippet ID + */ + snippet?: string + + /** + * Text contains snipped code. + * + * Not empty when shipped field is empty + */ + text?: string +} + +export default snippets as {[sectionName: string]: Snippet[]} diff --git a/web/src/settings/SettingsModal.tsx b/web/src/settings/SettingsModal.tsx index c38d1a80..2eaf4874 100644 --- a/web/src/settings/SettingsModal.tsx +++ b/web/src/settings/SettingsModal.tsx @@ -6,7 +6,8 @@ import {Link} from 'office-ui-fabric-react/lib/Link'; import {getContentStyles, getIconButtonStyles} from '../styles/modal'; import SettingsProperty from './SettingsProperty'; import {MonacoSettings, RuntimeType} from '../services/config'; -import {BuildParamsArgs, Connect, MonacoParamsChanges, SettingsState} from "../store"; +import { DEFAULT_FONT, getAvailableFonts } from '../services/fonts'; +import {BuildParamsArgs, Connect, MonacoParamsChanges, SettingsState} from '../store'; const WASM_SUPPORTED = 'WebAssembly' in window; @@ -36,6 +37,14 @@ const CURSOR_LINE_OPTS: IDropdownOption[] = [ {key: 'underline-thin', text: 'Underline thin'}, ]; +const FONT_OPTS: IDropdownOption[] = [ + {key: DEFAULT_FONT, text: 'System default'}, + ...getAvailableFonts().map(f => ({ + key: f.family, + text: f.label, + })) +]; + export interface SettingsChanges { monaco?: MonacoParamsChanges args?: BuildParamsArgs, @@ -49,11 +58,16 @@ export interface SettingsProps { dispatch?: (Action) => void } +interface SettingsModalState { + isOpen: boolean, + showWarning: boolean +} + @Connect(state => ({ settings: state.settings, monaco: state.monaco, })) -export default class SettingsModal extends React.Component { +export default class SettingsModal extends React.Component { private titleID = 'Settings'; private subtitleID = 'SettingsSubText'; private changes: SettingsChanges = {}; @@ -62,7 +76,7 @@ export default class SettingsModal extends React.Component + { + this.touchMonacoProperty('fontFamily', num?.key); + }} + />} + /> + { + this.touchMonacoProperty('fontLigatures', val); + }} + />} + /> { + dispatch(newImportFileAction(`${encodeURIComponent(name)}.go`, contents)); + }; +} + export function newMonacoParamsChangeDispatcher(changes: MonacoParamsChanges): Dispatcher { return (dispatch: DispatchFn, _: StateProvider) => { const current = config.monacoSettings; @@ -66,6 +72,7 @@ export function newSnippetLoadDispatcher(snippetID: string): Dispatcher { return; } + dispatch(newLoadingAction()); try { console.log('loading snippet %s', snippetID); const resp = await client.getSnippet(snippetID); diff --git a/web/src/utils/headerutils.ts b/web/src/utils/headerutils.ts new file mode 100644 index 00000000..f1f11623 --- /dev/null +++ b/web/src/utils/headerutils.ts @@ -0,0 +1,48 @@ +import snippets, {Snippet, SnippetType} from '../services/go/snippets'; +import {ContextualMenuItemType, IContextualMenuItem, IIconProps} from "office-ui-fabric-react"; + +const snippetIconTypeMapping: {[type: string]: IIconProps} = { + [SnippetType.Test]: { + iconName: 'TestPlan' + } +} + +export interface SnippetMenuItem extends Snippet {} + +/** + * Returns snippets list dropdown menu items + * @param handler menu item click handler + */ +export const getSnippetsMenuItems = (handler: (s: SnippetMenuItem) => void) => { + let menuItems: IContextualMenuItem[] = []; + Object.entries(snippets).forEach((s, i) => { + // this can be done in with ".map().reduce()" + // but imho simple imperative method will look + // more readable in future + const [sectionName, items] = s; + + const section = { + key: i.toString(), + text: sectionName, + itemType: ContextualMenuItemType.Header + }; + + const sectionItems = items.map((item, ii) => { + return { + key: `${i}-${ii}`, + text: item.label, + iconProps: getSnippetIconProps(item.type), + onClick: () => handler(item) + } as IContextualMenuItem; + }) + + menuItems.push(...[section, ...sectionItems]); + }) + return menuItems; +}; + +const getSnippetIconProps = (snippetType?: SnippetType): IIconProps|undefined => { + if (!snippetType) return; + if (!(snippetType in snippetIconTypeMapping)) return; + return snippetIconTypeMapping[snippetType]; +} \ No newline at end of file diff --git a/web/yarn.lock b/web/yarn.lock index 8876e64a..b4d42dde 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1470,7 +1470,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^15.x || ^16.x": +"@types/react@*": version "16.9.17" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.17.tgz#58f0cc0e9ec2425d1441dd7b623421a867aa253e" integrity sha512-UP27In4fp4sWF5JgyV6pwVPAQM83Fj76JOcg02X5BZcpSu5Wx+fP9RMqc2v0ssBoQIFvD5JdKY41gjJJKmw6Bg== @@ -1478,6 +1478,14 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/react@^16.x": + version "16.9.44" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.44.tgz#da84b179c031aef67dc92c33bd3401f1da2fa3bc" + integrity sha512-BtLoJrXdW8DVZauKP+bY4Kmiq7ubcJq+H/aCpRfvPF7RAT3RwR73Sg8szdc2YasbAlWBDrQ6Q+AFM0KwtQY+WQ== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -3408,9 +3416,14 @@ cssstyle@^1.0.0, cssstyle@^1.1.1: cssom "0.3.x" csstype@^2.2.0: - version "2.6.8" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.8.tgz#0fb6fc2417ffd2816a418c9336da74d7f07db431" - integrity sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA== + version "2.6.13" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.13.tgz#a6893015b90e84dd6e85d0e3b442a1e84f2dbe0f" + integrity sha512-ul26pfSQTZW8dcOnD2iiJssfXw0gdNVX9IJDH/X3K5DGPfj+fUYe3kB+swUY6BF3oZDxaID3AJt+9/ojSAE05A== + +csstype@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7" + integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw== cyclist@^1.0.1: version "1.0.1" @@ -6813,17 +6826,17 @@ mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "0.0.8" -monaco-editor-webpack-plugin@^1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-1.8.2.tgz#3721b8d9a3e2e41b154cf2a2955a7d7246c03714" - integrity sha512-g9G7A/lxQtpPsYaZFBqm73dwVkOziGUXExIR6iW7ksZUaiMkpvdTiE9O8edgdJGo+XtCmjycmIKB1Lt8VKbSTQ== +monaco-editor-webpack-plugin@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-1.9.0.tgz#5b547281b9f404057dc5d8c5722390df9ac90be6" + integrity sha512-tOiiToc94E1sb50BgZ8q8WK/bxus77SRrwCqIpAB5er3cpX78SULbEBY4YPOB8kDolOzKRt30WIHG/D6gz69Ww== dependencies: loader-utils "^1.2.3" -monaco-editor@^0.19.3: - version "0.19.3" - resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.19.3.tgz#1c994b3186c00650dbcd034d5370d46bf56c0663" - integrity sha512-2n1vJBVQF2Hhi7+r1mMeYsmlf18hjVb6E0v5SoMZyb4aeOmYPKun+CE3gYpiNA1KEvtSdaDHFBqH9d7Wd9vREg== +monaco-editor@*, monaco-editor@^0.20.0: + version "0.20.0" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea" + integrity sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ== move-concurrently@^1.0.1: version "1.0.1" @@ -8694,17 +8707,23 @@ react-error-overlay@^6.0.4: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.4.tgz#0d165d6d27488e660bc08e57bdabaad741366f7a" integrity sha512-ueZzLmHltszTshDMwyfELDq8zOA803wQ1ZuzCccXa1m57k1PxSHfflPD5W9YIiTXLs0JTLzoj6o1LuM5N6zzNA== -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.9.0: +react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.4, react-is@^16.9.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== -react-monaco-editor@^0.33.0: - version "0.33.0" - resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.33.0.tgz#822c331836ec39b1160faf22b0c722266f822460" - integrity sha512-zFo3A55QHCABYm4Xt0HWQMq10GI+oxhhCUGDYgzLksU1iGrdvHudUNXTDZvE43B1gM+cPyJ75jla/464kss8Iw== +react-is@^16.8.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-monaco-editor@^0.39.1: + version "0.39.1" + resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.39.1.tgz#2be0edf38f62eab127214c4d930f3d3ae369bfee" + integrity sha512-D2GKJlPxEIwkad9n1L7K/J3rLZ/UlMItS44YPO4cGQCcUJyKdwJkhOtk7q8gCqVd2uu7jyfyb8U34iSe+z9Aeg== dependencies: - "@types/react" "^15.x || ^16.x" + "@types/react" "^16.x" + monaco-editor "*" prop-types "^15.7.2" react-redux@^7.1.3: