Merge pull request #4273 from ethereum/userEnterModal

Added Enter Dialog.
pull/5370/head
yann300 1 year ago committed by GitHub
commit 09673c8215
  1. 3
      apps/remix-ide-e2e/src/helpers/init.ts
  2. 26
      apps/remix-ide-e2e/src/tests/migrateFileSystem.test.ts
  3. 4
      apps/remix-ide/src/app.js
  4. 28
      apps/remix-ide/src/assets/css/themes/remix-light_powaqg.css
  5. 14
      apps/remix-ide/src/walkthroughService.js
  6. 60
      libs/remix-ui/app/src/lib/remix-app/components/modals/enter.tsx
  7. 8
      libs/remix-ui/app/src/lib/remix-app/components/modals/matomo.tsx
  8. 7
      libs/remix-ui/app/src/lib/remix-app/interface/index.ts
  9. 60
      libs/remix-ui/app/src/lib/remix-app/remix-app.tsx
  10. 8
      libs/remix-ui/app/src/lib/remix-app/types/index.ts
  11. 1
      libs/remix-ui/home-tab/src/lib/components/workspaceTemplate.tsx

@ -11,8 +11,6 @@ export default function (browser: NightwatchBrowser, callback: VoidFunction, url
browser browser
.url(url || 'http://127.0.0.1:8080') .url(url || 'http://127.0.0.1:8080')
//.switchBrowserTab(0) //.switchBrowserTab(0)
.waitForElementVisible('[id="remixTourSkipbtn"]')
.click('[id="remixTourSkipbtn"]')
.perform((done) => { .perform((done) => {
if (!loadPlugin) return done() if (!loadPlugin) return done()
@ -54,7 +52,6 @@ export default function (browser: NightwatchBrowser, callback: VoidFunction, url
if (preloadPlugins) { if (preloadPlugins) {
initModules(browser, () => { initModules(browser, () => {
browser browser
.clickLaunchIcon('solidity') .clickLaunchIcon('solidity')
.waitForElementVisible('[for="autoCompile"]') .waitForElementVisible('[for="autoCompile"]')
.click('[for="autoCompile"]') .click('[for="autoCompile"]')

@ -11,8 +11,8 @@ module.exports = {
.maximizeWindow() .maximizeWindow()
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000) .waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]') .click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]') .pause(5000)
.click('[id="remixTourSkipbtn"]') .waitForElementVisible('*[data-id="remixIdeSidePanel"]', 10000)
}, },
'Should load the testmigration url and refresh and still have test data #group7': function (browser: NightwatchBrowser) { 'Should load the testmigration url and refresh and still have test data #group7': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true') browser.url('http://127.0.0.1:8080?e2e_testmigration=true')
@ -21,8 +21,9 @@ module.exports = {
.maximizeWindow() .maximizeWindow()
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000) .waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]') .click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]') .pause(5000)
.click('[id="remixTourSkipbtn"]').refreshPage() .waitForElementVisible('*[data-id="remixIdeSidePanel"]', 10000)
.refreshPage()
}, },
'should have indexedDB storage in terminal #group1 #group7': function (browser: NightwatchBrowser) { 'should have indexedDB storage in terminal #group1 #group7': function (browser: NightwatchBrowser) {
browser.assert.containsText('*[data-id="terminalJournal"]', 'indexedDB') browser.assert.containsText('*[data-id="terminalJournal"]', 'indexedDB')
@ -32,16 +33,17 @@ module.exports = {
.pause(6000) .pause(6000)
.switchBrowserTab(0) .switchBrowserTab(0)
.maximizeWindow() .maximizeWindow()
.waitForElementVisible('[id="remixTourSkipbtn"]') .pause(5000)
.click('[id="remixTourSkipbtn"]') .waitForElementVisible('*[data-id="remixIdeSidePanel"]', 10000)
.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.waitForElementVisible('div[data-id="filePanelFileExplorerTree"]') .waitForElementVisible('div[data-id="filePanelFileExplorerTree"]')
.openFile('README.txt') .openFile('README.txt')
.waitForElementVisible('*[id="editorView"]', 10000)
.getEditorValue((content) => { .getEditorValue((content) => {
browser.assert.ok(content.includes('Output from script will appear in remix terminal.')) browser.assert.ok(content.includes('Output from script will appear in remix terminal.'))
}) })
.click('*[data-id="treeViewLitreeViewItemcontracts"]') .click('*[data-id="treeViewLitreeViewItemcontracts"]')
.openFile('contracts/1_Storage.sol') .openFile('contracts/1_Storage.sol')
.waitForElementVisible('*[id="editorView"]', 10000)
.getEditorValue((content) => { .getEditorValue((content) => {
browser.assert.ok(content.includes('function retrieve() public view returns (uint256){')) browser.assert.ok(content.includes('function retrieve() public view returns (uint256){'))
}) })
@ -53,8 +55,8 @@ module.exports = {
.maximizeWindow() .maximizeWindow()
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000) .waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]') .click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]') .pause(5000)
.click('[id="remixTourSkipbtn"]') .waitForElementVisible('*[data-id="remixIdeSidePanel"]', 10000)
}, },
'Should generate error in migration by deleting indexedDB and falling back to local storage with test #group5': function (browser: NightwatchBrowser) { 'Should generate error in migration by deleting indexedDB and falling back to local storage with test #group5': function (browser: NightwatchBrowser) {
browser.url('http://127.0.0.1:8080?e2e_testmigration=true') browser.url('http://127.0.0.1:8080?e2e_testmigration=true')
@ -63,8 +65,8 @@ module.exports = {
.maximizeWindow().execute(('delete window.indexedDB')) .maximizeWindow().execute(('delete window.indexedDB'))
.waitForElementVisible('*[data-id="skipbackup-btn"]', 5000) .waitForElementVisible('*[data-id="skipbackup-btn"]', 5000)
.click('*[data-id="skipbackup-btn"]') .click('*[data-id="skipbackup-btn"]')
.waitForElementVisible('[id="remixTourSkipbtn"]') .pause(5000)
.click('[id="remixTourSkipbtn"]') .waitForElementVisible('*[data-id="remixIdeSidePanel"]', 10000)
}, },
'should have localstorage storage in terminal #group2 #group3 #group5': function (browser: NightwatchBrowser) { 'should have localstorage storage in terminal #group2 #group3 #group5': function (browser: NightwatchBrowser) {
browser.assert.containsText('*[data-id="terminalJournal"]', 'localstorage') browser.assert.containsText('*[data-id="terminalJournal"]', 'localstorage')
@ -74,6 +76,7 @@ module.exports = {
.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000) .waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.waitForElementVisible('div[data-id="filePanelFileExplorerTree"]') .waitForElementVisible('div[data-id="filePanelFileExplorerTree"]')
.openFile('TEST_README.txt') .openFile('TEST_README.txt')
.waitForElementVisible('*[id="editorView"]', 10000)
.getEditorValue((content) => { .getEditorValue((content) => {
browser.assert.equal(content, 'TEST README') browser.assert.equal(content, 'TEST README')
}) })
@ -96,6 +99,7 @@ module.exports = {
browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000) browser.waitForElementVisible('*[data-id="remixIdeSidePanel"]', 5000)
.click('*[data-id="treeViewLitreeViewItemtest_contracts/artifacts"]') .click('*[data-id="treeViewLitreeViewItemtest_contracts/artifacts"]')
.openFile('test_contracts/artifacts/Storage_metadata.json') .openFile('test_contracts/artifacts/Storage_metadata.json')
.waitForElementVisible('*[id="editorView"]', 10000)
.getEditorValue((content) => { .getEditorValue((content) => {
const metadata = JSON.parse(content) const metadata = JSON.parse(content)
browser.assert.equal(metadata.test, 'data') browser.assert.equal(metadata.test, 'data')

@ -131,7 +131,9 @@ class AppComponent {
'6fd22d6fe5549ad4c4d8fd3ca0b7816b.mod': 35 // remix desktop '6fd22d6fe5549ad4c4d8fd3ca0b7816b.mod': 35 // remix desktop
} }
this.showMatamo = matomoDomains[window.location.hostname] && !Registry.getInstance().get('config').api.exists('settings/matomo-analytics') this.showMatamo = matomoDomains[window.location.hostname] && !Registry.getInstance().get('config').api.exists('settings/matomo-analytics')
this.walkthroughService = new WalkthroughService(appManager, this.showMatamo) this.showEnter = matomoDomains[window.location.hostname] && !localStorage.getItem('hadUsageTypeAsked')
this.walkthroughService = new WalkthroughService(appManager, !this.showMatamo || !this.showEnter)
const hosts = ['127.0.0.1:8080', '192.168.0.101:8080', 'localhost:8080'] const hosts = ['127.0.0.1:8080', '192.168.0.101:8080', 'localhost:8080']
// workaround for Electron support // workaround for Electron support

@ -2386,8 +2386,8 @@ fieldset:disabled a.btn {
.btn-secondary { .btn-secondary {
color: #fff; color: #fff;
background-color: #a8b3bc; background-color: #8b98a3;
border-color: #a8b3bc; border-color: #8b98a3;
} }
.btn-secondary:hover { .btn-secondary:hover {
color: #fff; color: #fff;
@ -2401,8 +2401,8 @@ fieldset:disabled a.btn {
.btn-secondary.disabled, .btn-secondary.disabled,
.btn-secondary:disabled { .btn-secondary:disabled {
color: #fff; color: #fff;
background-color: #a8b3bc; background-color: #8b98a3;
border-color: #a8b3bc; border-color: #8b98a3;
} }
.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled):active,
.btn-secondary:not(:disabled):not(.disabled).active, .btn-secondary:not(:disabled):not(.disabled).active,
@ -2656,13 +2656,13 @@ fieldset:disabled a.btn {
} }
.btn-outline-secondary { .btn-outline-secondary {
color: #a8b3bc; color: #8b98a3;
border-color: #a8b3bc; border-color: #8b98a3;
} }
.btn-outline-secondary:hover { .btn-outline-secondary:hover {
color: #fff; color: #fff;
background-color: #a8b3bc; background-color: #8b98a3;
border-color: #a8b3bc; border-color: #8b98a3;
} }
.btn-outline-secondary:focus, .btn-outline-secondary:focus,
.btn-outline-secondary.focus { .btn-outline-secondary.focus {
@ -2670,15 +2670,15 @@ fieldset:disabled a.btn {
} }
.btn-outline-secondary.disabled, .btn-outline-secondary.disabled,
.btn-outline-secondary:disabled { .btn-outline-secondary:disabled {
color: #a8b3bc; color: #8b98a3;
background-color: transparent; background-color: transparent;
} }
.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled):active,
.btn-outline-secondary:not(:disabled):not(.disabled).active, .btn-outline-secondary:not(:disabled):not(.disabled).active,
.show > .btn-outline-secondary.dropdown-toggle { .show > .btn-outline-secondary.dropdown-toggle {
color: #fff; color: #fff;
background-color: #a8b3bc; background-color: #8b98a3;
border-color: #a8b3bc; border-color: #8b98a3;
} }
.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled):active:focus,
.btn-outline-secondary:not(:disabled):not(.disabled).active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,
@ -4731,7 +4731,7 @@ a.badge-primary.focus {
.badge-secondary { .badge-secondary {
color: #fff; color: #fff;
background-color: #a8b3bc; background-color: #8b98a3;
} }
a.badge-secondary:hover, a.badge-secondary:hover,
a.badge-secondary:focus { a.badge-secondary:focus {
@ -6271,7 +6271,7 @@ button.bg-dark:focus {
} }
.border-secondary { .border-secondary {
border-color: #a8b3bc !important; border-color: #8b98a3 !important;
} }
.border-success { .border-success {
@ -9308,7 +9308,7 @@ a.text-primary:focus {
} }
.text-secondary { .text-secondary {
color: #a8b3bc !important; color: #8b98a3 !important;
} }
a.text-secondary:hover, a.text-secondary:hover,

@ -11,13 +11,21 @@ const profile = {
} }
export class WalkthroughService extends Plugin { export class WalkthroughService extends Plugin {
constructor (appManager, showMatamo) { constructor (appManager, showWalkthrough) {
super(profile) super(profile)
appManager.event.on('activate', (plugin) => { let readyToStart = 0;
if (plugin.name === 'udapp' && !showMatamo) { /*appManager.event.on('activate', (plugin) => {
if (plugin.name === 'udapp') readyToStart++
if (readyToStart == 2 && showWalkthrough) {
this.start() this.start()
} }
}) })
appManager.event.on('activate', (plugin) => {
if (plugin.name === 'solidity') readyToStart++
if (readyToStart == 2 && showWalkthrough) {
this.start()
}
})*/
} }
start () { start () {

@ -0,0 +1,60 @@
import React, {useContext, useEffect, useState} from 'react'
import {AppContext} from '../../context/context'
import {UsageTypes} from '../../types'
import { type } from 'os'
interface EnterDialogProps {
hide: boolean,
handleUserChoice: (userChoice: UsageTypes) => void,
}
const EnterDialog = (props: EnterDialogProps) => {
const [visibility, setVisibility] = useState<boolean>(false)
const {showEnter} = useContext(AppContext)
useEffect(() => {
setVisibility(!props.hide)
}, [props.hide])
const enterAs = async (uType) => {
props.handleUserChoice(uType)
}
const modalClass = (visibility && showEnter) ? "d-flex" : "d-none"
return (
<div
data-id={`EnterModalDialogContainer-react`}
data-backdrop="static"
data-keyboard="false"
className={"modal " + modalClass}
role="dialog"
>
<div className="modal-dialog align-self-center pb-4" role="document">
<div
tabIndex={-1}
className={'modal-content remixModalContent mb-4'}
onKeyDown={({keyCode}) => {
}}
>
<div className="modal-header d-flex flex-column">
<h3 className='text-dark'>Welcome to Remix IDE</h3>
<div className='d-flex flex-row pt-2'>
<h6 className="modal-title" data-id={`EnterModalDialogModalTitle-react`}>
To load the project with the most efficient setup we would like to know your experience type.
</h6>
<i className="text-dark fal fa-door-open text-center" style={{minWidth: "100px", fontSize: "xxx-large"}}></i>
</div>
</div>
<div className="modal-body text-break remixModalBody d-flex flex-row p-3 justify-content-between" data-id={`EnterModalDialogModalBody-react`}>
<button className="btn-secondary" data-id="beginnerbtn" style={{minWidth: "100px"}} onClick={() => {enterAs(UsageTypes.Beginner)}}>Beginner</button>
<button className="btn-secondary" data-id="tutorbtn" style={{minWidth: "100px"}} onClick={() => {enterAs(UsageTypes.Tutor)}}>Teacher</button>
<button className="btn-secondary" data-id="prototyperbtn" style={{minWidth: "100px"}} onClick={() => {enterAs(UsageTypes.Prototyper)}}>Prototyper</button>
<button className="btn-secondary" data-id="productionbtn" style={{minWidth: "100px"}} onClick={() => {enterAs(UsageTypes.Production)}}>Production User</button>
</div>
</div>
</div>
</div>
)
}
export default EnterDialog

@ -25,11 +25,7 @@ const MatomoDialog = (props) => {
</p> </p>
<p>We realize that our users have sensitive information in their code and that their privacy - your privacy - must be protected.</p> <p>We realize that our users have sensitive information in their code and that their privacy - your privacy - must be protected.</p>
<p> <p>
All data collected through Matomo is stored on our own server - no data is ever given to third parties. Our analytics reports are public:{' '} All data collected through Matomo is stored on our own server - no data is ever given to third parties.
<a href="https://matomo.ethereum.org/index.php?module=MultiSites&action=index&idSite=23&period=day&date=yesterday" target="_blank" rel="noreferrer">
take a look
</a>
.
</p> </p>
<p>We do not collect nor store any personally identifiable information (PII).</p> <p>We do not collect nor store any personally identifiable information (PII).</p>
<p> <p>
@ -61,14 +57,12 @@ const MatomoDialog = (props) => {
const declineModal = async () => { const declineModal = async () => {
settings.updateMatomoAnalyticsChoice(false) settings.updateMatomoAnalyticsChoice(false)
_paq.push(['optUserOut']) _paq.push(['optUserOut'])
appManager.call('walkthrough', 'start')
setVisible(false) setVisible(false)
} }
const handleModalOkClick = async () => { const handleModalOkClick = async () => {
_paq.push(['forgetUserOptOut']) _paq.push(['forgetUserOptOut'])
settings.updateMatomoAnalyticsChoice(true) settings.updateMatomoAnalyticsChoice(true)
appManager.call('walkthrough', 'start')
setVisible(false) setVisible(false)
} }

@ -37,3 +37,10 @@ export interface ModalState {
focusModal: AppModal, focusModal: AppModal,
focusToaster: {message: (string | JSX.Element), timestamp: number } focusToaster: {message: (string | JSX.Element), timestamp: number }
} }
export interface forceChoiceModal {
id: string
title?: string,
message: string | JSX.Element,
}

@ -2,6 +2,7 @@ import React, {useEffect, useRef, useState} from 'react'
import './style/remix-app.css' import './style/remix-app.css'
import {RemixUIMainPanel} from '@remix-ui/panel' import {RemixUIMainPanel} from '@remix-ui/panel'
import MatomoDialog from './components/modals/matomo' import MatomoDialog from './components/modals/matomo'
import EnterDialog from './components/modals/enter'
import OriginWarning from './components/modals/origin-warning' import OriginWarning from './components/modals/origin-warning'
import DragBar from './components/dragbar/dragbar' import DragBar from './components/dragbar/dragbar'
import {AppProvider} from './context/provider' import {AppProvider} from './context/provider'
@ -10,12 +11,21 @@ import DialogViewPlugin from './components/modals/dialogViewPlugin'
import {AppContext} from './context/context' import {AppContext} from './context/context'
import {IntlProvider, FormattedMessage} from 'react-intl' import {IntlProvider, FormattedMessage} from 'react-intl'
import {CustomTooltip} from '@remix-ui/helper' import {CustomTooltip} from '@remix-ui/helper'
import {UsageTypes} from './types'
declare global {
interface Window {
_paq: any
}
}
const _paq = (window._paq = window._paq || [])
interface IRemixAppUi { interface IRemixAppUi {
app: any app: any
} }
const RemixApp = (props: IRemixAppUi) => { const RemixApp = (props: IRemixAppUi) => {
const [appReady, setAppReady] = useState<boolean>(false) const [appReady, setAppReady] = useState<boolean>(false)
const [showEnterDialog, setShowEnterDialog] = useState<boolean>(true)
const [hideSidePanel, setHideSidePanel] = useState<boolean>(false) const [hideSidePanel, setHideSidePanel] = useState<boolean>(false)
const [maximiseTrigger, setMaximiseTrigger] = useState<number>(0) const [maximiseTrigger, setMaximiseTrigger] = useState<number>(0)
const [resetTrigger, setResetTrigger] = useState<number>(0) const [resetTrigger, setResetTrigger] = useState<number>(0)
@ -37,6 +47,8 @@ const RemixApp = (props: IRemixAppUi) => {
if (props.app) { if (props.app) {
activateApp() activateApp()
} }
const hadUsageTypeAsked = localStorage.getItem('hadUsageTypeAsked')
setShowEnterDialog(!hadUsageTypeAsked)
}, []) }, [])
function setListeners() { function setListeners() {
@ -75,22 +87,66 @@ const RemixApp = (props: IRemixAppUi) => {
const value = { const value = {
settings: props.app.settings, settings: props.app.settings,
showMatamo: props.app.showMatamo, showMatamo: props.app.showMatamo,
showEnter: props.app.showEnter,
appManager: props.app.appManager, appManager: props.app.appManager,
modal: props.app.notification, modal: props.app.notification,
layout: props.app.layout layout: props.app.layout
} }
const handleUserChosenType = async (type) => {
setShowEnterDialog(false)
localStorage.setItem('hadUsageTypeAsked', type)
await props.app.appManager.call('walkthrough', 'start')
// Use the type to setup the UI accordingly
switch (type) {
case UsageTypes.Beginner: {
await props.app.appManager.call('manager', 'activatePlugin', 'LearnEth')
// const wName = 'Playground'
// const workspaces = await props.app.appManager.call('filePanel', 'getWorkspaces')
// if (!workspaces.find((workspace) => workspace.name === wName)) {
// await props.app.appManager.call('filePanel', 'createWorkspace', wName, 'playground')
// }
// await props.app.appManager.call('filePanel', 'switchToWorkspace', { name: wName, isLocalHost: false })
_paq.push(['trackEvent', 'enterDialog', 'usageType', 'beginner'])
break
}
case UsageTypes.Tutor: {
_paq.push(['trackEvent', 'enterDialog', 'usageType', 'tutor'])
break
}
case UsageTypes.Prototyper: {
_paq.push(['trackEvent', 'enterDialog', 'usageType', 'prototyper'])
break
}
case UsageTypes.Production: {
_paq.push(['trackEvent', 'enterDialog', 'usageType', 'production'])
break
}
default: throw new Error()
}
}
return ( return (
//@ts-ignore //@ts-ignore
<IntlProvider locale={locale.code} messages={locale.messages}> <IntlProvider locale={locale.code} messages={locale.messages}>
<AppProvider value={value}> <AppProvider value={value}>
<OriginWarning></OriginWarning> <OriginWarning></OriginWarning>
<MatomoDialog hide={!appReady}></MatomoDialog> <MatomoDialog hide={!appReady} okFn={() => {setShowEnterDialog(true)}}></MatomoDialog>
<EnterDialog hide={!showEnterDialog} handleUserChoice={(type) => handleUserChosenType(type)}></EnterDialog>
<div className={`remixIDE ${appReady ? '' : 'd-none'}`} data-id="remixIDE"> <div className={`remixIDE ${appReady ? '' : 'd-none'}`} data-id="remixIDE">
<div id="icon-panel" data-id="remixIdeIconPanel" className="custom_icon_panel iconpanel bg-light"> <div id="icon-panel" data-id="remixIdeIconPanel" className="custom_icon_panel iconpanel bg-light">
{props.app.menuicons.render()} {props.app.menuicons.render()}
</div> </div>
<div ref={sidePanelRef} id="side-panel" data-id="remixIdeSidePanel" className={`sidepanel border-right border-left ${hideSidePanel ? 'd-none' : ''}`}> <div
ref={sidePanelRef}
id="side-panel"
data-id="remixIdeSidePanel"
className={`sidepanel border-right border-left ${hideSidePanel ? 'd-none' : ''}`}
>
{props.app.sidePanel.render()} {props.app.sidePanel.render()}
</div> </div>
<DragBar <DragBar

@ -5,4 +5,12 @@ export const enum ModalTypes {
password = 'password', password = 'password',
default = 'default', default = 'default',
form = 'form', form = 'form',
forceChoice = 'forceChoice'
}
export const enum UsageTypes {
Beginner = 1,
Tutor,
Prototyper,
Production,
} }

@ -14,7 +14,6 @@ interface WorkspaceTemplateProps {
function WorkspaceTemplate({gsID, workspaceTitle, description, projectLogo, callback}: WorkspaceTemplateProps) { function WorkspaceTemplate({gsID, workspaceTitle, description, projectLogo, callback}: WorkspaceTemplateProps) {
const themeFilter = useContext(ThemeContext) const themeFilter = useContext(ThemeContext)
console.log("theme ", themeFilter)
return ( return (
<div className="d-flex remixui_home_workspaceTemplate"> <div className="d-flex remixui_home_workspaceTemplate">
<button <button

Loading…
Cancel
Save