From 77f8aeec402028aea2858cccc70f7a06a3fe7285 Mon Sep 17 00:00:00 2001 From: Pablo Mayrgundter Date: Wed, 4 Dec 2024 23:25:16 +0100 Subject: [PATCH] UI Refresh: Apps, LeftDrawer, themed Typography, reorg; Hot-reload esbuild and more (#1271) * cypress: skip broken tests. tracked in issue #1269 * cypress: skip another test. tracked in issue #1269 * checkpoint * apps: url state. sidebar: title padding cleanup. controlbutton: tooltips fixed. notes: avatar valign top, dateline linebreaks. * tests passing. * tests passing. * layout: working in landscape; checkpoint * layout: left side aligned * unit tests pass * lint passes * versions: move fetch logic to hook, more testing * SideDrawer: mobile bottom drawer mostly working * cypress passing * cypress passinger * cypress passingerer * Delete api.min.js --------- Signed-off-by: Pablo Mayrgundter --- .eslintrc.js | 2 + cypress.config.js | 4 +- cypress/e2e/appStore/appStore.cy.js | 17 - cypress/e2e/apps/apps.cy.js | 17 + cypress/e2e/notes-100/access-notes-list.cy.js | 3 +- .../e2e/notes-100/access-shared-note.cy.js | 6 +- cypress/e2e/notes-100/create-a-note.cy.js | 3 +- cypress/e2e/notes-100/select-a-note.cy.js | 5 +- cypress/e2e/open/100/open-model-dialog.cy.js | 9 +- cypress/e2e/parallel.sh | 2 +- .../e2e/placemarks-100/marker-selection.cy.js | 9 +- .../placemarks-100/marker-visibility.cy.js | 20 +- .../view-100/access-element-properties.cy.js | 3 +- package.json | 13 +- public/icons/mod_logo.jpeg | Bin 0 -> 4681 bytes public/mod.html | 18 + public/mod.js | 65 ++ public/widgets/mod.html | 112 ++++ src/BaseRoutes.jsx | 10 +- src/Components/About/AboutControl.jsx | 5 +- src/Components/About/AboutControl.test.jsx | 15 +- src/Components/About/AboutDescription.jsx | 6 +- src/Components/About/AboutDialog.jsx | 53 +- src/Components/About/Discord.svg | 38 ++ src/Components/AppBar.jsx | 4 +- src/Components/AppStore/AppStorePanel.jsx | 52 -- .../AppStore/AppStoreSideDrawerControl.jsx | 98 --- src/Components/Apps/AppsControl.jsx | 29 + .../AppsListing.jsx} | 56 +- .../AppsMessagesHandler.js} | 0 src/Components/Apps/AppsPanel.jsx | 50 ++ .../AppsRegistry.json} | 9 +- src/Components/Apps/hashState.js | 17 + src/Components/Buttons.jsx | 54 +- src/Components/Buttons.test.jsx | 56 +- src/Components/Camera/hashState.js | 14 + src/Components/ControlsGroup.jsx | 35 -- src/Components/CutPlane/CutPlaneMenu.jsx | 138 ++--- src/Components/CutPlane/CutPlaneMenu.test.jsx | 51 +- src/Components/CutPlane/hashState.js | 103 ++++ src/Components/Dialog.jsx | 48 +- .../{ElementGroup.jsx => ElementsControl.jsx} | 0 ...roup.test.jsx => ElementsControl.test.jsx} | 18 +- src/Components/Help/HelpControl.jsx | 12 +- src/Components/Help/HelpControl.test.jsx | 8 +- src/Components/LoadingBackdrop.jsx | 16 +- src/Components/Logo/Logo.jsx | 35 +- src/Components/Markers/MarkerControl.jsx | 137 +---- src/Components/Markers/hashState.js | 130 ++++ src/Components/NavTree/NavTree.jsx | 8 +- src/Components/NavTree/NavTreeControl.jsx | 6 +- src/Components/NavTree/NavTreeItem.jsx | 3 - src/Components/NavTree/NavTreePanel.jsx | 61 +- src/Components/NavTree/TypesNavTree.jsx | 5 +- src/Components/NavTree/hashState.js | 8 +- src/Components/Notes/NoteCard.jsx | 11 +- src/Components/Notes/NoteCardCreate.jsx | 10 +- src/Components/Notes/NoteContent.jsx | 3 +- src/Components/Notes/NoteFooter.jsx | 25 +- src/Components/Notes/Notes.jsx | 252 ++++---- src/Components/Notes/NotesControl.jsx | 2 +- src/Components/Notes/NotesNavBar.jsx | 39 +- src/Components/Notes/NotesPanel.jsx | 23 +- src/Components/Notes/NotesPanel.test.jsx | 18 + src/Components/Notes/component.js | 3 + src/Components/Notes/hashState.js | 73 ++- src/Components/Open/OpenModelControl.test.jsx | 5 +- src/Components/Open/OpenModelDialog.jsx | 5 +- src/Components/Open/PleaseLogin.jsx | 2 +- src/Components/Open/component.js | 3 + src/Components/OperationsGroup.jsx | 73 --- src/Components/Profile/ProfileControl.jsx | 13 +- src/Components/Properties/Properties.jsx | 63 +- src/Components/Properties/PropertiesPanel.jsx | 39 +- .../Properties/PropertiesPanel.test.jsx | 18 + src/Components/Properties/component.js | 1 + src/Components/Properties/hashState.js | 19 +- src/Components/Search/SearchBar.jsx | 73 +-- src/Components/Share/ShareControl.jsx | 25 +- src/Components/Share/hashState.js | 9 + .../SideDrawer/HorizonResizerButton.jsx | 126 ++-- src/Components/SideDrawer/Panel.jsx | 114 ++-- src/Components/SideDrawer/PanelTitle.jsx | 46 -- src/Components/SideDrawer/PanelWithTitle.jsx | 39 -- src/Components/SideDrawer/SideDrawer.jsx | 107 ++-- src/Components/SideDrawer/SideDrawer.test.jsx | 62 +- .../SideDrawer/SideDrawerPanels.test.jsx | 23 - .../SideDrawer/VerticalResizerButton.jsx | 117 ++-- src/Components/TabbedDialog.fixture.jsx | 30 - src/Components/TabbedDialog.jsx | 66 -- src/Components/TabbedDialog.test.jsx | 43 -- src/Components/Versions/VersionsControl.jsx | 5 +- .../Versions/VersionsPanel.fixture.js | 9 +- src/Components/Versions/VersionsPanel.jsx | 63 +- .../Versions/VersionsPanel.test.jsx | 60 +- ...xture.jsx => VersionsTimeline.fixture.jsx} | 22 +- src/Components/Versions/VersionsTimeline.jsx | 26 +- .../Versions/VersionsTimeline.test.jsx | 37 +- src/Components/Versions/useVersions.jsx | 50 ++ src/Components/Versions/useVersions.test.jsx | 21 + src/Containers/AppsSideDrawer.jsx | 49 ++ src/Containers/BottomBar.jsx | 28 + src/Containers/CadView.jsx | 58 +- src/Containers/CadView.test.jsx | 11 +- src/Containers/ControlsGroup.jsx | 49 ++ src/Containers/ControlsGroupAndDrawer.jsx | 112 ---- src/Containers/NavTreeAndVersionsDrawer.jsx | 85 +++ .../NavTreeAndVersionsDrawer.test.jsx | 85 +++ src/Containers/NotesAndPropertiesDrawer.jsx | 59 ++ .../NotesAndPropertiesDrawer.test.jsx | 45 ++ src/Containers/OperationsGroup.jsx | 66 ++ .../OperationsGroup.test.jsx | 0 src/Containers/OperationsGroupAndDrawer.jsx | 14 +- src/Containers/RootLandscape.jsx | 87 +++ src/Containers/TabbedPanels.jsx | 246 ++++++++ src/Share.fixture.jsx | 14 +- src/Share.jsx | 12 +- src/Styles.jsx | 4 +- src/WidgetApi/ApiConnection.js | 4 +- src/WidgetApi/ApiConnectionIframe.js | 31 +- src/WidgetApi/WidgetApi.js | 12 +- src/__mocks__/api-handlers.js | 6 - src/assets/LogoB.svg | 2 +- src/assets/LogoBWithDomain.svg | 2 +- src/assets/icons/AppStore.svg | 7 - src/assets/icons/Copy.svg | 3 - src/assets/icons/Discord.svg | 3 - src/assets/icons/GitHub.svg | 3 - src/assets/icons/Info.svg | 7 - src/assets/icons/Information.svg | 3 - src/assets/icons/Moon.svg | 3 - src/assets/icons/Sun.svg | 3 - src/assets/icons/Tree.svg | 16 +- src/index.jsx | 54 +- src/loader/Loader.js | 17 +- src/loader/urls.js | 1 - src/store/AppsSlice.js | 24 + src/store/{AppSlice.js => BrowserSlice.js} | 17 +- src/store/CutPlanesSlice.js | 25 + src/store/NotesSlice.js | 114 ++-- src/store/OpenSlice.js | 7 + src/store/ShareSlice.js | 13 + src/store/SideDrawerSlice.jsx | 27 +- src/store/UISlice.js | 26 - src/store/useStore.js | 10 +- src/store/useStore.test.js | 4 +- src/theme/Components.js | 7 +- src/theme/Palette.js | 4 + src/theme/Theme.fixture.jsx | 6 +- src/theme/Theme.jsx | 2 + src/theme/Typography.js | 20 +- tools/esbuild/common.js | 1 + tools/esbuild/serve.js | 2 + tools/esbuild/vars.cypress.js | 2 + tools/esbuild/vars.prod.js | 7 +- tools/jest/setupTests.js | 6 +- tools/jest/vars.jest.js | 2 + yarn.lock | 571 ++++++++++++++++-- 158 files changed, 3511 insertions(+), 2156 deletions(-) delete mode 100644 cypress/e2e/appStore/appStore.cy.js create mode 100644 cypress/e2e/apps/apps.cy.js create mode 100644 public/icons/mod_logo.jpeg create mode 100644 public/mod.html create mode 100644 public/mod.js create mode 100644 public/widgets/mod.html create mode 100755 src/Components/About/Discord.svg delete mode 100644 src/Components/AppStore/AppStorePanel.jsx delete mode 100644 src/Components/AppStore/AppStoreSideDrawerControl.jsx create mode 100644 src/Components/Apps/AppsControl.jsx rename src/Components/{AppStore/AppStoreListing.jsx => Apps/AppsListing.jsx} (66%) rename src/Components/{AppStore/AppStoreMessagesHandler.js => Apps/AppsMessagesHandler.js} (100%) create mode 100644 src/Components/Apps/AppsPanel.jsx rename src/Components/{AppStore/AppStoreData.json => Apps/AppsRegistry.json} (50%) create mode 100644 src/Components/Apps/hashState.js delete mode 100644 src/Components/ControlsGroup.jsx rename src/Components/{ElementGroup.jsx => ElementsControl.jsx} (100%) rename src/Components/{ElementGroup.test.jsx => ElementsControl.test.jsx} (87%) create mode 100644 src/Components/Notes/NotesPanel.test.jsx create mode 100644 src/Components/Notes/component.js create mode 100644 src/Components/Open/component.js delete mode 100644 src/Components/OperationsGroup.jsx create mode 100644 src/Components/Properties/PropertiesPanel.test.jsx create mode 100644 src/Components/Properties/component.js delete mode 100644 src/Components/SideDrawer/PanelTitle.jsx delete mode 100644 src/Components/SideDrawer/PanelWithTitle.jsx delete mode 100644 src/Components/SideDrawer/SideDrawerPanels.test.jsx delete mode 100644 src/Components/TabbedDialog.fixture.jsx delete mode 100644 src/Components/TabbedDialog.jsx delete mode 100644 src/Components/TabbedDialog.test.jsx rename src/Components/Versions/{Timeline.fixture.jsx => VersionsTimeline.fixture.jsx} (52%) create mode 100644 src/Components/Versions/useVersions.jsx create mode 100644 src/Components/Versions/useVersions.test.jsx create mode 100644 src/Containers/AppsSideDrawer.jsx create mode 100644 src/Containers/BottomBar.jsx create mode 100644 src/Containers/ControlsGroup.jsx delete mode 100644 src/Containers/ControlsGroupAndDrawer.jsx create mode 100644 src/Containers/NavTreeAndVersionsDrawer.jsx create mode 100644 src/Containers/NavTreeAndVersionsDrawer.test.jsx create mode 100644 src/Containers/NotesAndPropertiesDrawer.jsx create mode 100644 src/Containers/NotesAndPropertiesDrawer.test.jsx create mode 100644 src/Containers/OperationsGroup.jsx rename src/{Components => Containers}/OperationsGroup.test.jsx (100%) create mode 100644 src/Containers/RootLandscape.jsx create mode 100644 src/Containers/TabbedPanels.jsx delete mode 100644 src/assets/icons/AppStore.svg delete mode 100644 src/assets/icons/Copy.svg delete mode 100755 src/assets/icons/Discord.svg delete mode 100644 src/assets/icons/GitHub.svg delete mode 100644 src/assets/icons/Info.svg delete mode 100644 src/assets/icons/Information.svg delete mode 100644 src/assets/icons/Moon.svg delete mode 100644 src/assets/icons/Sun.svg create mode 100644 src/store/AppsSlice.js rename src/store/{AppSlice.js => BrowserSlice.js} (54%) create mode 100644 src/store/CutPlanesSlice.js create mode 100644 src/store/ShareSlice.js diff --git a/.eslintrc.js b/.eslintrc.js index 237cd3667..48c715834 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -37,6 +37,7 @@ module.exports = { 'react', 'jsx-a11y', 'jsdoc', + 'eslint-plugin-react-compiler', ], rules: { 'arrow-parens': ['error', 'always'], @@ -101,6 +102,7 @@ module.exports = { 'react/jsx-tag-spacing': ['error', {beforeSelfClosing: 'never'}], 'react/prop-types': 'off', 'react/self-closing-comp': 'error', + // TODO(pablo): re-enable.. got this down to 10. 'react-compiler/react-compiler': 'error', 'require-await': 'error', 'semi': ['error', 'never'], 'space-infix-ops': ['error'], diff --git a/cypress.config.js b/cypress.config.js index bbf0108d0..d524cc3f1 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -24,13 +24,13 @@ module.exports = import('./tools/esbuild/vars.cypress.js').then(({ env: { // Used in support/models.js to setup intercepts, should match what code // under tests will be using. - // TODO(pablo): cypress chrome seems to not have OPFS, so using original - // instead of RAW_GIT_PROXY_URL_NEW AUTH0_DOMAIN: vars.AUTH0_DOMAIN, GITHUB_BASE_URL: vars.GITHUB_BASE_URL, GITHUB_BASE_URL_UNAUTHENTICATED: vars.GITHUB_BASE_URL_UNAUTHENTICATED, MSW_IS_ENABLED: true, OAUTH2_CLIENT_ID: vars.OAUTH2_CLIENT_ID, + // TODO(pablo): cypress chrome seems to not have OPFS, so using original + // instead of RAW_GIT_PROXY_URL_NEW RAW_GIT_PROXY_URL: vars.RAW_GIT_PROXY_URL, RAW_GIT_PROXY_URL_NEW: vars.RAW_GIT_PROXY_URL_NEW, }, diff --git a/cypress/e2e/appStore/appStore.cy.js b/cypress/e2e/appStore/appStore.cy.js deleted file mode 100644 index 68eb2127b..000000000 --- a/cypress/e2e/appStore/appStore.cy.js +++ /dev/null @@ -1,17 +0,0 @@ -describe('appStore side drawer', () => { - context('enable/disable feature using url parameter', () => { - beforeEach(() => { - cy.setCookie('isFirstTime', '1') - cy.visit('/') - }) - - it('should not show app-store icon when url parameter is not present', () => { - cy.findByRole('button', {name: /Open App Store/}).should('not.exist') - }) - - it.skip('should show app-store icon when url parameter is present', () => { - cy.routerNavigate('/share/v/p?feature=apps', {replace: true}) - cy.findByRole('button', {name: /Open App Store/}).should('exist') - }) - }) -}) diff --git a/cypress/e2e/apps/apps.cy.js b/cypress/e2e/apps/apps.cy.js new file mode 100644 index 000000000..5addcee62 --- /dev/null +++ b/cypress/e2e/apps/apps.cy.js @@ -0,0 +1,17 @@ +describe('apps side drawer', () => { + context('enable/disable feature using url parameter', () => { + beforeEach(() => { + cy.setCookie('isFirstTime', '1') + cy.visit('/') + }) + + it('should not show apps icon when url parameter is not present', () => { + cy.findByRole('button', {name: /Open Apps/}).should('not.exist') + }) + + it.skip('should show apps icon when url parameter is present', () => { + cy.routerNavigate('/share/v/p?feature=apps', {replace: true}) + cy.findByRole('button', {name: /Open Apps/}).should('exist') + }) + }) +}) diff --git a/cypress/e2e/notes-100/access-notes-list.cy.js b/cypress/e2e/notes-100/access-notes-list.cy.js index 717721550..ca9d7d9f9 100644 --- a/cypress/e2e/notes-100/access-notes-list.cy.js +++ b/cypress/e2e/notes-100/access-notes-list.cy.js @@ -1,4 +1,5 @@ import '@percy/cypress' +import {TITLE_NOTES} from '../../../src/Components/Notes/component' import {homepageSetup, returningUserVisitsHomepageWaitForModel} from '../../support/utils' @@ -11,7 +12,7 @@ describe('Notes 100: Access notes list', () => { beforeEach(() => cy.get('[data-testid="control-button-notes"]').click()) it('Notes visible - Screen', () => { cy.get('[data-testid="list-notes"]') - cy.get('[data-testid="panelTitle"]').contains('NOTES') + cy.get(`[data-testid="PanelTitle-${TITLE_NOTES}"]`).contains(TITLE_NOTES) }) }) }) diff --git a/cypress/e2e/notes-100/access-shared-note.cy.js b/cypress/e2e/notes-100/access-shared-note.cy.js index cd9383449..21af7854f 100644 --- a/cypress/e2e/notes-100/access-shared-note.cy.js +++ b/cypress/e2e/notes-100/access-shared-note.cy.js @@ -1,4 +1,5 @@ import '@percy/cypress' +import {TITLE_NOTE, TITLE_NOTES} from '../../../src/Components/Notes/component' import {waitForModel, homepageSetup, setIsReturningUser} from '../../support/utils' /** {@link https://github.com/bldrs-ai/Share/issues/1072} */ @@ -13,8 +14,7 @@ describe('Notes 100: Access shared note', () => { waitForModel() }) it('Notes open - Screen', () => { - // Panel title to contain 'NOTES' string - cy.get('[data-testid="panelTitle"]').contains('NOTES') + cy.get(`[data-testid="PanelTitle-${TITLE_NOTES}"]`).contains(TITLE_NOTES) // List of notes to be visible cy.get('.MuiList-root').should('exist') cy.percySnapshot() @@ -27,7 +27,7 @@ describe('Notes 100: Access shared note', () => { }) it('Panel title to contain NOTE string and back button', () => { - cy.get('[data-testid="panelTitle"]').contains('NOTE') + cy.get(`[data-testid="PanelTitle-${TITLE_NOTE}"]`).contains(TITLE_NOTE) cy.get('[data-testid="Back to the list"]').should('exist') }) diff --git a/cypress/e2e/notes-100/create-a-note.cy.js b/cypress/e2e/notes-100/create-a-note.cy.js index 39b97a8fe..c267ee763 100644 --- a/cypress/e2e/notes-100/create-a-note.cy.js +++ b/cypress/e2e/notes-100/create-a-note.cy.js @@ -1,4 +1,5 @@ import '@percy/cypress' +import {TITLE_NOTE_ADD} from '../../../src/Components/Notes/component' import { auth0Login, homepageSetup, @@ -19,7 +20,7 @@ describe('Notes 100: Create a note', () => { it('Notes list switches to display only create note card and back to the list when nav backbutton is pressed', () => { cy.get('[data-testid="Back to the list"]').should('exist') cy.get('[placeholder="Note Title"]').should('exist') - cy.get('[data-testid="panelTitle"]').contains('ADD A NOTE') + cy.get(`[data-testid="PanelTitle-${TITLE_NOTE_ADD}"]`).contains(TITLE_NOTE_ADD) cy.percySnapshot() cy.get('[data-testid="Back to the list"]').click() cy.get('[data-testid="list-notes"]').should('exist') diff --git a/cypress/e2e/notes-100/select-a-note.cy.js b/cypress/e2e/notes-100/select-a-note.cy.js index 247d8889c..c4d928101 100644 --- a/cypress/e2e/notes-100/select-a-note.cy.js +++ b/cypress/e2e/notes-100/select-a-note.cy.js @@ -1,4 +1,5 @@ import '@percy/cypress' +import {TITLE_NOTE} from '../../../src/Components/Notes/component' import { homepageSetup, returningUserVisitsHomepageWaitForModel, @@ -22,7 +23,9 @@ describe('Notes 100: Select a note', () => { cy.get('[data-testid="list-notes"] > :nth-child(4) > [data-testid="note-card"] p').contains('testComment_1') cy.get('[data-testid="list-notes"] > :nth-child(5) > [data-testid="note-card"] p').contains('testComment_2') - cy.get('[data-testid="panelTitle"]').should('have.text', 'NOTE') + cy.get(`[data-testid="PanelTitle-${TITLE_NOTE}"]`).debug() + + cy.get(`[data-testid="PanelTitle-${TITLE_NOTE}"]`).should('have.text', TITLE_NOTE) cy.get('[data-testid="Back to the list"]').click() diff --git a/cypress/e2e/open/100/open-model-dialog.cy.js b/cypress/e2e/open/100/open-model-dialog.cy.js index b8e4bd734..f670e2cd6 100644 --- a/cypress/e2e/open/100/open-model-dialog.cy.js +++ b/cypress/e2e/open/100/open-model-dialog.cy.js @@ -1,13 +1,14 @@ import '@percy/cypress' +import {LABEL_GITHUB} from '../../../../src/Components/Open/component' import { auth0Login, homepageSetup, returningUserVisitsHomepageWaitForModel, } from '../../../support/utils' import { - setupVirtualPathIntercept, - waitForModelReady, - } from '../../../support/models' + setupVirtualPathIntercept, + waitForModelReady, +} from '../../../support/models' /** {@link https://github.com/bldrs-ai/Share/issues/1159}*/ @@ -47,7 +48,7 @@ describe('Open 100: Open model dialog', () => { const interceptTag = 'ghOpenModelLoad' it('Choose the path to the model on GitHub -> model is loaded into the scene', () => { cy.get('[data-testid="tab-github"]').click() - cy.findByText('Github').click() + cy.findByText(LABEL_GITHUB).click() cy.findByLabelText('Organization', {timeout: 5000}).click() cy.contains('@cypresstester').click() cy.findByLabelText('Repository', {timeout: 5000}).eq(0).click() diff --git a/cypress/e2e/parallel.sh b/cypress/e2e/parallel.sh index 4fc941a00..91a5d67bb 100755 --- a/cypress/e2e/parallel.sh +++ b/cypress/e2e/parallel.sh @@ -18,7 +18,7 @@ run_cy_spec() { echo "Running cypress specs in parallel..." # Misc -run_cy_spec misc cypress/e2e/appStore,cypress/e2e/hide-feat,cypress/e2e/home,cypress/e2e/ifc-model,cypress/e2e/integration +run_cy_spec misc cypress/e2e/apps,cypress/e2e/hide-feat,cypress/e2e/home,cypress/e2e/ifc-model,cypress/e2e/integration # Then conventional for EPIC in create-100 open notes-100 profile-100 versions-100 view-100 ; do diff --git a/cypress/e2e/placemarks-100/marker-selection.cy.js b/cypress/e2e/placemarks-100/marker-selection.cy.js index b3201e4b7..c016eb82a 100644 --- a/cypress/e2e/placemarks-100/marker-selection.cy.js +++ b/cypress/e2e/placemarks-100/marker-selection.cy.js @@ -1,5 +1,6 @@ import '@percy/cypress' import {Raycaster, Vector2, Vector3} from 'three' +import {TITLE_NOTES} from '../../../src/Components/Notes/component' import {homepageSetup, returningUserVisitsHomepageWaitForModel, auth0Login, @@ -18,13 +19,13 @@ describe('Placemarks 100: Not visible when notes is not open', () => { beforeEach(() => { cy.get('[data-testid="control-button-notes"]').click() cy.get('[data-testid="list-notes"]') - cy.get('[data-testid="panelTitle"]').contains('NOTES') - + cy.get(`[data-testid="PanelTitle-${TITLE_NOTES}"]`).contains(TITLE_NOTES) cy.window().then((window) => { win = window }) - // eslint-disable-next-line cypress/no-unnecessary-waiting, no-magic-numbers - cy.wait(1000) + const waitTimeMs = 1000 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(waitTimeMs) }) it('should select a marker and url hash should change', () => { const {markerObjects, camera, domElement} = win.markerScene diff --git a/cypress/e2e/placemarks-100/marker-visibility.cy.js b/cypress/e2e/placemarks-100/marker-visibility.cy.js index 2f4ea8328..651483560 100644 --- a/cypress/e2e/placemarks-100/marker-visibility.cy.js +++ b/cypress/e2e/placemarks-100/marker-visibility.cy.js @@ -1,4 +1,5 @@ import '@percy/cypress' +import {TITLE_NOTES} from '../../../src/Components/Notes/component' import {homepageSetup, returningUserVisitsHomepageWaitForModel} from '../../support/utils' @@ -8,23 +9,22 @@ describe('Placemarks 100: Not visible when notes is not open', () => { context('Returning user visits homepage', () => { beforeEach(returningUserVisitsHomepageWaitForModel) it('MarkerControl should not exist', () => { - cy.get('[data-testid="markerControl"]').should('not.exist') + cy.get('[data-testid="markerControl"]').should('not.exist') }) context('Open Notes and MarkerControl should exist', () => { - let win + let win beforeEach(() => { cy.get('[data-testid="control-button-notes"]').click() - cy.get('[data-testid="list-notes"]') - cy.get('[data-testid="panelTitle"]').contains('NOTES') - + cy.get(`[data-testid="PanelTitle-${TITLE_NOTES}"]`).contains(TITLE_NOTES) cy.window().then((window) => { - win = window + win = window }) - // eslint-disable-next-line cypress/no-unnecessary-waiting, no-magic-numbers - cy.wait(1500) - }) + const waitTimeMs = 1500 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(waitTimeMs) + }) it('MarkerControl should exist', () => { // Access the scene objects const markers = win.markerScene.markerObjects @@ -34,7 +34,7 @@ describe('Placemarks 100: Not visible when notes is not open', () => { // Check visibility of markers markers.forEach((marker) => { - // eslint-disable-next-line no-unused-expressions + // eslint-disable-next-line no-unused-expressions expect(marker.userData.id).to.exist }) diff --git a/cypress/e2e/view-100/access-element-properties.cy.js b/cypress/e2e/view-100/access-element-properties.cy.js index a5f9ee347..96ce1db03 100644 --- a/cypress/e2e/view-100/access-element-properties.cy.js +++ b/cypress/e2e/view-100/access-element-properties.cy.js @@ -1,4 +1,5 @@ import '@percy/cypress' +import {TITLE} from '../../../src/Components/Properties/component' import { homepageSetup, setIsReturningUser, @@ -26,7 +27,7 @@ describe('View 100: Access elements property', () => { it('Side drawer containing properties shall be visible', () => { cy.get('[data-testid="control-button-properties"]').should('be.visible') - cy.get('[data-testid="panelTitle"]').contains('PROPERTIES') + cy.get(`[data-testid="PanelTitle-${TITLE}"]`).contains(TITLE) cy.percySnapshot() }) }) diff --git a/package.json b/package.json index 679479219..e91323159 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bldrs", - "version": "1.0.1165", + "version": "1.0.1181", "main": "src/index.jsx", "license": "AGPL-3.0", "homepage": "https://github.com/bldrs-ai/Share", @@ -67,12 +67,13 @@ "@bldrs-ai/ifclib": "5.3.3", "@emotion/react": "11.10.0", "@emotion/styled": "11.10.0", + "@fontsource/roboto": "^5.1.0", "@iconscout/react-unicons": "2.0.0", - "@mui/icons-material": "5.11.9", + "@mui/icons-material": "5.16.8", "@mui/lab": "5.0.0-alpha.95", - "@mui/material": "5.10.1", - "@mui/styled-engine": "5.10.1", - "@mui/styles": "5.11.13", + "@mui/material": "5.16.8", + "@mui/styled-engine": "5.16.8", + "@mui/styles": "5.16.8", "@octokit/rest": "20.1.1", "@sentry/react": "7.61.0", "@sentry/tracing": "7.61.0", @@ -88,6 +89,7 @@ "esbuild-copy-static-files": "0.1.0", "esbuild-plugin-progress": "1.0.1", "esbuild-plugin-svgr": "2.0.0", + "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "html-webpack-plugin": "5.5.3", "js-cookie": "3.0.5", "material-ui-popup-state": "5.0.4", @@ -127,7 +129,6 @@ "@testing-library/dom": "8.19.1", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", - "@testing-library/react-hooks": "8.0.1", "@tsconfig/recommended": "1.0.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", diff --git a/public/icons/mod_logo.jpeg b/public/icons/mod_logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..dc11f35c1bbd275607b46788c6a399b610df1634 GIT binary patch literal 4681 zcmb7H2UL^U^8XSNY61y_Dj=bQw9pK_Ns%fjMT!(b2|Y9mDuM!rB5eU_(iB8`??{#2 zix5zxC`d;E6&3!$UH@<2zIXQQyWgBU_sraxTYvM-J(@at3m|m0&T0W55CDKkA8<4S zXaHnj@G+4JghWtEC=>#LQd3ZnQ^KfWFdAwa8aORI0#1vhrJ+GEA&?9xMn*;$9Wx6P ziiIACyL7#H|oe)c71J_{7NhZLTJaduAf`@GkLAK zAmtBN@QQ+eCNI|1zu(ed+LNS{5=H8V=~0r-6a)ny>Rj_`viKRDT-UQSiKo zbssFmW+1FLui7Ee=P-Zh6BD!lZ#G2ZYq&Fmo9QprK2s9DYQ>GeL}l=NUv2JQtCg5I zSl`GD=H$e=&AzH;nu%pxd01o~&?nM=OWnOOeQ3XqFjb;`U@CV0d3oET$m_lt+*Qc@ zecH0Zq5?B!n}=0VSh?+suEMWo5#aLi$gnnA7yMEXGiP&d!O-m zF`qx!EUUdD=SpSWplSK)^#2z< zqC`f{JG?F#HWMmL9ilcT6Ia$Ozr7gg!s5!yRV?&Ru=h#{bms+_yy4^?Q?AHZ&|7k8 z?x_R&NJvg5rMY^u-l&7;kk1E&d6YM}NIg_sj`4a?YHU{Z*>6Z?EGntg!h?Q1(_6sd zKj$3JS{J)n#k?AV2)Jk`G*IR+P|lajeA}g7*@yShmk?^##V1F=?J3mE*AgqriKxVbMjnyI8F%^$Un`J1kRyhi}(^0Fdw zLhoN#0m+&j2>=xT9;QGL#tU)te>edDbU>?vPAHY*3N>q43TCw;i=%XM`{$yH<8d@e z5F-PTkyBAY$tiwfjD$7_07W3_(NeKxBMca6HFaZepKNX(6MKA6d{#M1^1OpDmyCwj z=sYi<#O2_))<4ikt^z)EWcd-w@6`UZxej8)bg+a}v|5Wfv*TH;#?bi}(X&`Xlrcq` zswsP+|ExDJR-~MY!0KZxby+%?-PYMps-#TF?84{QUCBnW&wJ@Fh*eq|f&-oi4f}a_ zEvqq&Lc;ibcpfyn)!3lt^-Zh0N0`SGy1B2{yy7A(%vlEuMBKhHBQGbg#HXv8I8zG0 z(bo(26_qgm;Lef_P(eB)gfk3-~*lHaP71pOR?bD{>qA*hMFSH zlZF9>%_Fy=-uKq;WjDUWjt~l-f=L)YrdDx3(zw&TGeo$v~}CJ z{(PrF$i@7ol}rCtySwC&l)k{=3OxL(#5~Pp!8b&n{qhNAxcPXE<$ySYH9zNeecuN2 zuz7tu;Q>}yH~}5qj@Mo`3s`(f7ySLho6(8*gP0k=q#30orMco>%Y-+H#0rJ&83Tp> zMXIp+HMtSnc`orPsXHCmM_)Y}lEU)EsyQwRVD*(+{gQ~{nI$|TZ9?JWd1Hhwon}91 z*5c__Wna?P_WaOsCk_0#@A1|vCsExkJ@$fYw7G{ix&J;FJuh(t<@HZJR-mRWUo(m=7 z!$RPB47?#XMjtSuvQifMns5^v4U|&Wjw*DKexi;~5?gPhahPH@wu@4B>70EGeM8OtxoXPg*=`Ci2H<}9>m(m zWNZ^=*TchSgiZIO)+h^Nb06Wpgv1tOD_FIYm7TKv`;LGUC!_Bja(h0dLcX>e%YG|Z z)Qy-|JlK11#ppI9)j76P{{sv2>F$SAL;_{uU!=|HMmhsR%(ghrMc zt!n^TkCvt1Zx2@CbMqpEKaAz*26p6YkL2D8Sld$vv2yaAG{90vuf3IMXKU{KF>p~n zgOCvyrrWQ&7CI4_)pSt{vw>x+{JO}`AHd1fw%crREgz$Pc^o!ben#g;yZWealjmZm z$7%b{E*<}(yn~!4%7O3hZdKktP0K!!;)q^NlB_P6^qU%w$M9cgm-39Gq#SS2PqW1T z22xYMp5JZIPkriUc|``q9H+d~eC-YhSBuhl!$#1bomT6Z)1s_9FqRz-VF7xho; z$R|;nF&@->-`Exa)r4S{T9p$xwL9%H{b~t}M*yt$Jf0(nbx)@1Qgx~qv9R5>yh*mk zYVBru26^+UKL2a>FvXi+G?xa0N|JyvS;MZ*%Q&t{Y7R=;@T@wWE(3S-qTE^80tw=J zi9yli!JEP&Wi!^-8+2Iq`(n*>mm$NIu;1k0YUiPS3bBY)&{GaOZu`4MFV_(tpD(COuGg{|U#fURwCfvA5<_EAG@z^jnG2&_^1HS}XTgRR zXHMrnxbGUA*XX<(aqcc&$bGWbMfbGcnY+tD77zSrFSjlmHKehTc`j77A;PW%<1Fu- zYRcR)813=>kxoqurvR!L5tlxMwVIkPg=Va}oMLhl3poOQgDYna*tj&^shX+rc-`Q? zL1G`7mp`j7ZwLBbPf~5)e0(fA*36ME?awC-Kt}qnBR&l&{u3G-Qe4!QCiUE*#xC@- zV|qp20)p8QB4ZJPJ3~OWo`_wfVygk{^W5#vGq;cHf9CcR32ZDs6QW=F{Z9_s{!>1L zSQQS>e)jxNRaGF#j2ZdY_u9^{Eb2H3LaefwF^1S%{Ht>LnSKfUgf54%g+go~(ONc6Y+Zq6TdD@Uf7x&DMs>$I?=3se?b$HBPdq-9~kOK8$@$h@1w z9I|rx;VFi1iCE)<0%6@Y|45FG_c`g`H-Yxyt?%-&T5}*>1&~_X%X{=nzO;r{p}B6r zw;+ndtkfQ-YZ z-ly_tVV%!EC?1Gmz%_WBSecDPXIBc=^6MH`;y=L`*80S#nmd~KAum^rlZ~3PvXrJJ z9(GGVgQn*~I6ByvB;s4l;8gMVR|-qmrP2}7e5y03Grdq*SPaWkkZ67kZTCpzms7)A zt9P}Bif0(va?6|6#P5_cEXl!Vw;b7Ng9eSo-Wt(!yxw+G_v?<|Z<4u7nath&1;?Dq zz6?_iw-O~3YP_S9S-G~S%Cr2+Jv>T>y)@D3#dYeJXRhus45RtxYj;A29|<8LQhYyl zC#lyStD1fhQbpmDRqxPeeiy0ZZ>G9so;5cCL}%Dk3(FWRc}`Q5Hd^H(oR{cZV$;;9 z9Ax_CTQgD_c<#WczbSEK>wum0x=Y_>p42vNz zx9r4O>%dv&te7BeoE;nx5@mVH9dBvis3HgST^50*RbsME&AQI0R2qME&StH^m-$E!Fi%%xp0ZnQ8GQQSym3W)9GbRXtT^)PvTJZTYHc zH5>vRh$c+vah=G~=K|RYrapHAQH_4MH2tH*?eJA~p!5AA-}aUZA18%xk$2&|O%LkO s%~HKa@0~|-w4&IgNlXJ0_%THIhnV|Vg)yg>v=>5UoOmohRv%6OAF~Y%t^fc4 literal 0 HcmV?d00001 diff --git a/public/mod.html b/public/mod.html new file mode 100644 index 000000000..7cf2cb6b5 --- /dev/null +++ b/public/mod.html @@ -0,0 +1,18 @@ + + + + + + + + + + +
+ + + +
    +
    + + diff --git a/public/mod.js b/public/mod.js new file mode 100644 index 000000000..8313e0c31 --- /dev/null +++ b/public/mod.js @@ -0,0 +1,65 @@ +const {ClientWidgetApi, PostmessageTransport} = mxwidgets() + + +/** @param {string} msg */ +function log(msg) { + const logElt = document.getElementById('logItems') + logElt.innerHTML += `
  • ${msg}
  • ` +} + +// Define the widget configuration +const widget = { + id: 'bldrs-share', + name: 'Embedded Share App', + type: 'm.custom', + url: 'https://example.com/widget', + creatorUserId: '@user:matrix.org', + origin: '*', +} + +// Reference the iframe element +const iframe = document.getElementById('share-app') + +// Create a PostmessageTransport driver +const driver = new PostmessageTransport(window, iframe.contentWindow) + +// Instantiate the ClientWidgetApi +const clientWidgetApi = new ClientWidgetApi(widget, iframe, driver) + +console.log('clientWidgetApi', clientWidgetApi) + +clientWidgetApi.on('ready', () => { + console.log('ON READY') + clientWidgetApi.updateVisibility(true).then(() => console.log('Widget knows it is visible now')) + // clientWidgetApi.transport.send('com.example.my_action', {isExample: true}) +}) + +/** @param {string} eventName */ +function register(eventName, cb) { + console.log('registering:', eventName) + clientWidgetApi.on(`action:ai.bldrs-share.${eventName}`, (event) => { + cb(eventName, event) + // clientWidgetApi.transport.reply(event, {success: true, response: 'Acknowledged!'}) + }) +} + +const events = [ + 'ChangeViewSettings', + 'HiddenElements', + 'ModelLoaded', + 'ChangeViewSettings', + 'LoadModel', + 'HighlightElements', + 'SelectElements', + 'UnhideElements', + 'HideElements', +] +events.forEach((name) => { + register(name, (eventName, e) => console.log(`HANDLER ON ${eventName}:`, e.detail)) +}) + +register('SelectionChanged', (eventName, e) => { + const data = e.detail.data + const currentSelection = data.current[0] + log(`GUID: ${currentSelection}`) +}) diff --git a/public/widgets/mod.html b/public/widgets/mod.html new file mode 100644 index 000000000..e1a2b4286 --- /dev/null +++ b/public/widgets/mod.html @@ -0,0 +1,112 @@ + + + + + + + + + MOD Parts App + + + +
    + + +
    + +

    Message received is shown here:

    +
    + + + + + + diff --git a/src/BaseRoutes.jsx b/src/BaseRoutes.jsx index 30cb60aaa..d41227989 100644 --- a/src/BaseRoutes.jsx +++ b/src/BaseRoutes.jsx @@ -1,13 +1,13 @@ import React, {useEffect} from 'react' import {Outlet, Route, Routes, useLocation, useNavigate} from 'react-router-dom' -import ShareRoutes from './ShareRoutes' -import {checkOPFSAvailability, setUpGlobalDebugFunctions} from './OPFS/utils' -import debug from './utils/debug' -import {navWith} from './utils/navigate' -import useStore from './store/useStore' import * as Sentry from '@sentry/react' import {useAuth0} from './Auth0/Auth0Proxy' +import {checkOPFSAvailability, setUpGlobalDebugFunctions} from './OPFS/utils' +import ShareRoutes from './ShareRoutes' import {initializeOctoKitAuthenticated, initializeOctoKitUnauthenticated} from './net/github/OctokitExport' +import useStore from './store/useStore' +import debug from './utils/debug' +import {navWith} from './utils/navigate' const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes) diff --git a/src/Components/About/AboutControl.jsx b/src/Components/About/AboutControl.jsx index 348a37b55..a2cc9ad8c 100644 --- a/src/Components/About/AboutControl.jsx +++ b/src/Components/About/AboutControl.jsx @@ -34,7 +34,7 @@ export default function AboutControl() { setIsDialogDisplayed={setIsAboutVisible} hashPrefix={HASH_PREFIX_ABOUT} placement='right' - buttonTestId='control-button-about' + dataTestId={testId} > ) } + + +export const testId = 'control-button-about' diff --git a/src/Components/About/AboutControl.test.jsx b/src/Components/About/AboutControl.test.jsx index a42c6c184..4bbcdb222 100644 --- a/src/Components/About/AboutControl.test.jsx +++ b/src/Components/About/AboutControl.test.jsx @@ -3,11 +3,10 @@ import React from 'react' import {fireEvent, render, waitFor} from '@testing-library/react' import {HelmetStoreRouteThemeCtx} from '../../Share.fixture' import * as FirstTime from '../../privacy/firstTime' -import AboutControl from './AboutControl' -import PkgJson from '../../../package.json' +import AboutControl, {testId} from './AboutControl' +import {BLDRS_MISSION} from './AboutDialog' -const bldrsVersionString = `Bldrs: ${PkgJson.version}` describe('AboutControl', () => { beforeEach(() => { Cookies.remove(FirstTime.COOKIE_NAME) @@ -15,21 +14,21 @@ describe('AboutControl', () => { it('renders the AboutControl button', () => { const {getByTestId} = render(, {wrapper: HelmetStoreRouteThemeCtx}) - const aboutControl = getByTestId('control-button-about') + const aboutControl = getByTestId(testId) expect(aboutControl).toBeInTheDocument() }) it('renders AboutDialog when control is pressed', () => { - const {getByTitle, getByText} = render(, {wrapper: HelmetStoreRouteThemeCtx}) - const aboutControl = getByTitle(bldrsVersionString) + const {getByTestId, getByText} = render(, {wrapper: HelmetStoreRouteThemeCtx}) + const aboutControl = getByTestId(testId) fireEvent.click(aboutControl) - const dialogTitle = getByText('Build every thing together') + const dialogTitle = getByText(BLDRS_MISSION) expect(dialogTitle).toBeInTheDocument() }) it('updates the document title when the dialog is open', async () => { const {getByTestId} = render(, {wrapper: HelmetStoreRouteThemeCtx}) - const aboutControl = getByTestId('control-button-about') + const aboutControl = getByTestId(testId) fireEvent.click(aboutControl) await(waitFor(() => expect(document.title).toBe('About — bldrs.ai'))) }) diff --git a/src/Components/About/AboutDescription.jsx b/src/Components/About/AboutDescription.jsx index cb025ca5c..71e72b5cd 100644 --- a/src/Components/About/AboutDescription.jsx +++ b/src/Components/About/AboutDescription.jsx @@ -1,13 +1,13 @@ -import React from 'react' +import React, {ReactElement} from 'react' import Box from '@mui/material/Box' import Typography from '@mui/material/Typography' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' /** * A miniature view of the App to show as a guide in the About dialog. * - * @return {React.ReactComponent} + * @return {ReactElement} */ export default function AboutDescription({setIsDialogDisplayed}) { const theme = useTheme() diff --git a/src/Components/About/AboutDialog.jsx b/src/Components/About/AboutDialog.jsx index c8a7b6f9f..1c9b3e115 100644 --- a/src/Components/About/AboutDialog.jsx +++ b/src/Components/About/AboutDialog.jsx @@ -2,6 +2,7 @@ import React, {ReactElement} from 'react' import {Helmet} from 'react-helmet-async' import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' +import SvgIcon from '@mui/material/SvgIcon' import Typography from '@mui/material/Typography' import Dialog from '../Dialog' import {LogoBWithDomain} from '../Logo/Logo' @@ -9,12 +10,10 @@ import {LogoBWithDomain} from '../Logo/Logo' // import PrivacyControl from './PrivacyControl' import GitHubIcon from '@mui/icons-material/GitHub' import EmailIcon from '@mui/icons-material/Email' -import DiscordIcon from '../../assets/icons/Discord.svg' +import DiscordIcon from './Discord.svg' /** - * The AboutDialog component - * * @property {boolean} isDialogDisplayed Passed to Dialog to be controlled * @property {Function} setIsDialogDisplayed Passed to Dialog to be controlled * @property {Function} onClose Callback when closed @@ -24,16 +23,19 @@ export default function AboutDialog({isDialogDisplayed, setIsDialogDisplayed, on return ( - - - - Build every thing together - - ) - } + headerText={( + <> + + + + {BLDRS_MISSION} + + )} isDialogDisplayed={isDialogDisplayed} setIsDialogDisplayed={setIsDialogDisplayed} actionTitle='OK' @@ -63,13 +65,13 @@ function AboutContent() { Welcome to Bldrs - Share! - +
    Use the Open dialog to open IFC or STEP models from:
    • Local files - no data is uploaded to our servers
    • Files hosted on GitHub, public or private
    - +
    Position the camera, Select elements, Crop using section planes and Collaborate with your team via Notes. For files on GitHub share the @@ -79,17 +81,20 @@ function AboutContent() { Comments and suggestions welcome! - - Discord - - - GitHub - - - info@bldrs.ai - + + Discord + + + GitHub + + + info@bldrs.ai + ) } + + +export const BLDRS_MISSION = 'Build Every Thing Together' diff --git a/src/Components/About/Discord.svg b/src/Components/About/Discord.svg new file mode 100755 index 000000000..14c7c0bdb --- /dev/null +++ b/src/Components/About/Discord.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/src/Components/AppBar.jsx b/src/Components/AppBar.jsx index b17dd9bcc..ca55bcca6 100644 --- a/src/Components/AppBar.jsx +++ b/src/Components/AppBar.jsx @@ -2,7 +2,7 @@ import React, {ReactElement} from 'react' import MuiAppBar from '@mui/material/AppBar' import Stack from '@mui/material/Stack' import Toolbar from '@mui/material/Toolbar' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import ControlsGroup from './ControlsGroup' import LoginMenu from './LoginMenu' import SearchBar from './Search/SearchBar' @@ -37,7 +37,7 @@ export default function AppBar({isRepoActive}) { sx={{width: '100%'}} > - + diff --git a/src/Components/AppStore/AppStorePanel.jsx b/src/Components/AppStore/AppStorePanel.jsx deleted file mode 100644 index e9e9097c6..000000000 --- a/src/Components/AppStore/AppStorePanel.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, {ReactElement} from 'react' -import Box from '@mui/material/Box' -import useStore from '../../store/useStore' -import {BackButton, CloseButton, FullScreenButton} from '../Buttons' -import PanelWithTitle from '../SideDrawer/PanelWithTitle' -import {AppStoreListing, AppStoreIFrame} from './AppStoreListing' - - -/** @return {ReactElement} */ -export function AppStorePanel() { - const toggleAppStoreDrawer = useStore((state) => state.toggleAppStoreDrawer) - - return ( - } - > - - - ) -} - -/** @return {ReactElement} */ -export function AppPreviewPanel({item}) { - const toggleAppStoreDrawer = useStore((state) => state.toggleAppStoreDrawer) - const setSelectedStoreApp = useStore((state) => state.setSelectedStoreApp) - return ( - - - { - setSelectedStoreApp(null) - }} - /> - { - window.open(item.action, '_blank') - }} - /> - - - - } - > - - - ) -} diff --git a/src/Components/AppStore/AppStoreSideDrawerControl.jsx b/src/Components/AppStore/AppStoreSideDrawerControl.jsx deleted file mode 100644 index 8f32c2cf0..000000000 --- a/src/Components/AppStore/AppStoreSideDrawerControl.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, {useRef} from 'react' -import Box from '@mui/material/Box' -import Paper from '@mui/material/Paper' -import useTheme from '@mui/styles/useTheme' -import {useIsMobile} from '../Hooks' -import useStore from '../../store/useStore' -import HorizonResizerButton from '../SideDrawer/HorizonResizerButton' -import VerticalResizerButton from '../SideDrawer/VerticalResizerButton' -import {AppPreviewPanel, AppStorePanel} from './AppStorePanel' - -/** - * @return {React.Component} - */ -export default function AppStoreSideDrawer() { - const isAppStoreOpen = useStore((state) => state.isAppStoreOpen) - const sidebarWidth = useStore((state) => state.appStoreSidebarWidth) - const setAppStoreSidebarWidth = useStore((state) => state.setAppStoreSidebarWidth) - const sidebarHeight = useStore((state) => state.appStoreSidebarHeight) - const setAppStoreSidebarHeight = useStore((state) => state.setAppStoreSidebarHeight) - const isMobile = useIsMobile() - const sidebarRef = useRef(null) - const theme = useTheme() - const thickness = 10 - const selectedStoreApp = useStore((state) => state.selectedStoreApp) - - return ( - - e.preventDefault()} - > - {!isMobile && - - } - {isMobile && - - } - {/* Content */} - - - {!selectedStoreApp ? - : - - } - - - - - ) -} diff --git a/src/Components/Apps/AppsControl.jsx b/src/Components/Apps/AppsControl.jsx new file mode 100644 index 000000000..3d5a7de03 --- /dev/null +++ b/src/Components/Apps/AppsControl.jsx @@ -0,0 +1,29 @@ +import React, {ReactElement} from 'react' +import useStore from '../../store/useStore' +import {ControlButtonWithHashState} from '../Buttons' +import {HASH_PREFIX_APPS} from './hashState' +import WidgetsIcon from '@mui/icons-material/WidgetsOutlined' + + +/** + * This button hosts the AppsDialog component and toggles it open and + * closed. + * + * @return {ReactElement} + */ +export default function AppsControl() { + const isAppsVisible = useStore((state) => state.isAppsVisible) + const setIsAppsVisible = useStore((state) => state.setIsAppsVisible) + return ( + } + isDialogDisplayed={isAppsVisible} + setIsDialogDisplayed={setIsAppsVisible} + hashPrefix={HASH_PREFIX_APPS} + placement='bottom' + > + <> + + ) +} diff --git a/src/Components/AppStore/AppStoreListing.jsx b/src/Components/Apps/AppsListing.jsx similarity index 66% rename from src/Components/AppStore/AppStoreListing.jsx rename to src/Components/Apps/AppsListing.jsx index f57814c2b..0ba0384a0 100644 --- a/src/Components/AppStore/AppStoreListing.jsx +++ b/src/Components/Apps/AppsListing.jsx @@ -1,42 +1,37 @@ -import React, {useCallback} from 'react' +import React, {ReactElement, useCallback} from 'react' import Box from '@mui/material/Box' -import Paper from '@mui/material/Paper' -import Grid from '@mui/material/Unstable_Grid2' -import Typography from '@mui/material/Typography' -import useStore from '../../store/useStore' import Card from '@mui/material/Card' +import CardActionArea from '@mui/material/CardActionArea' import CardContent from '@mui/material/CardContent' import CardMedia from '@mui/material/CardMedia' -import {CardActionArea} from '@mui/material' -import {IFrameCommunicationChannel} from './AppStoreMessagesHandler' -import AppStoreData from './AppStoreData.json' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import Grid from '@mui/material/Unstable_Grid2' +import useStore from '../../store/useStore' +import AppsRegistry from './AppsRegistry.json' +import {IFrameCommunicationChannel} from './AppsMessagesHandler' -/** @return {React.Component} */ -export function AppStoreListing() { - const setSelectedStoreApp = useStore((state) => state.setSelectedStoreApp) +/** @return {ReactElement} */ +export function AppsListing() { + const setSelectedApp = useStore((state) => state.setSelectedApp) return ( - <> - - {AppStoreData.map((item, index) => ( - - - - ))} - - + + {AppsRegistry.map((item, index) => ( + + + + ))} + ) } -/** @return {React.Component} */ -export function AppStoreEntry({ - item, - clickHandler, -}) { +/** @return {ReactElement} */ +function AppsEntry({item, clickHandler}) { return ( @@ -69,8 +64,9 @@ export function AppStoreEntry({ ) } -/** @return {React.Component} */ -export function AppStoreIFrame({ + +/** @return {ReactElement} */ +export function AppIFrame({ item, }) { const appFrameRef = useCallback((elt) => { diff --git a/src/Components/AppStore/AppStoreMessagesHandler.js b/src/Components/Apps/AppsMessagesHandler.js similarity index 100% rename from src/Components/AppStore/AppStoreMessagesHandler.js rename to src/Components/Apps/AppsMessagesHandler.js diff --git a/src/Components/Apps/AppsPanel.jsx b/src/Components/Apps/AppsPanel.jsx new file mode 100644 index 000000000..4a69b4f45 --- /dev/null +++ b/src/Components/Apps/AppsPanel.jsx @@ -0,0 +1,50 @@ +import React, {ReactElement} from 'react' +import ButtonGroup from '@mui/material/ButtonGroup' +import useStore from '../../store/useStore' +import {BackButton, CloseButton, FullScreenButton} from '../Buttons' +import Panel from '../SideDrawer/Panel' +import {AppsListing, AppIFrame} from './AppsListing' +import {removeHashParams} from './hashState' + + +/** @return {ReactElement} */ +export default function AppsPanel() { + const setIsAppsVisible = useStore((state) => state.setIsAppsVisible) + + + /** Hide panel and remove hash state */ + function onClose() { + setIsAppsVisible(false) + removeHashParams() + } + + + return ( + + + + ) +} + + +/** @return {ReactElement} */ +export function AppPreviewPanel({item}) { + const toggleAppsDrawer = useStore((state) => state.toggleAppsDrawer) + const setSelectedApp = useStore((state) => state.setSelectedApp) + return ( + + setSelectedApp(null)}/> + window.open(item.action, '_blank')}/> + + + } + data-testid='AppsPreviewPanel' + > + + + ) +} diff --git a/src/Components/AppStore/AppStoreData.json b/src/Components/Apps/AppsRegistry.json similarity index 50% rename from src/Components/AppStore/AppStoreData.json rename to src/Components/Apps/AppsRegistry.json index ffcbfb9c0..e8d21e72a 100644 --- a/src/Components/AppStore/AppStoreData.json +++ b/src/Components/Apps/AppsRegistry.json @@ -1,7 +1,14 @@ [ + { + "appName": "MOD", + "description": "Parts Supplier", + "image": "http://localhost:8080/icons/mod_logo.jpeg", + "action": "/widgets/mod.html", + "icon": "http://localhost:8080/icons/mod_logo.jpeg" + }, { "appName": "vyzn", - "description": "Perform environmental analysis on your model - DRAFT", + "description": "Life Cycle Analysis", "image": "https://www.vyzn.tech/wp-content/themes/stuiq-base/assets/images/logo.svg", "action": "/widgets/example.html", "icon": "https://www.vyzn.tech/wp-content/uploads/2023/02/cropped-loop_favicon-180x180.png" diff --git a/src/Components/Apps/hashState.js b/src/Components/Apps/hashState.js new file mode 100644 index 000000000..2c6c30530 --- /dev/null +++ b/src/Components/Apps/hashState.js @@ -0,0 +1,17 @@ +import {hasParams, removeParams} from '../../utils/location' + + +/** The prefix to use for the apps state token */ +export const HASH_PREFIX_APPS = 'apps' + + +/** Removes hash params for apps */ +export function removeHashParams() { + removeParams(HASH_PREFIX_APPS) +} + + +/** @return {boolean} */ +export function isVisibleInitially() { + return hasParams(HASH_PREFIX_APPS) +} diff --git a/src/Components/Buttons.jsx b/src/Components/Buttons.jsx index 8e9ccb593..c6b0a87bc 100644 --- a/src/Components/Buttons.jsx +++ b/src/Components/Buttons.jsx @@ -14,17 +14,18 @@ import BackIcon from '../assets/icons/Back.svg' /** - * An icon button with a tooltip. THe button will use a given buttonTestId or - * the tip title as the data-testid on the button. + * An icon button with a tooltip. The button will use a given dataTestId or the + * tip title as the data-testid on the button. * * @property {string} title Tooltip text * @property {Function} onClick Callback * @property {object} icon Button icon * @property {string} placement Tooltip placement + * @property {Array} [children] Optional child elts, e.g. a hosted dialog * @property {boolean} [enabled] Whether the button can be clicked. Default: true * @property {boolean} [selected] Selected state. Default: false * @property {string} [size] Size enum: 'small', 'medium' or 'large'. Default: 'medium' - * @property {string} [buttonTestId] Internal attribute for component testing. + * @property {string} [dataTestId] Internal attribute for component testing. * @return {ReactElement} */ export function TooltipIconButton({ @@ -32,39 +33,54 @@ export function TooltipIconButton({ onClick, icon, placement, + children, enabled = true, selected = false, - aboutInfo = true, color, size, variant, - buttonTestId, + dataTestId, }) { assertDefined(title, onClick, icon, placement) const isMobile = useIsMobile() const isHelpTooltipsVisible = useStore((state) => state.isHelpTooltipsVisible) && !isMobile - - const [openLocal, setOpenLocal] = useState(false) - + const [isTooltipVisible, setIsTooltipVisible] = useState(false) + // This moves the tooltip close to the icon instead of edge of button, which + // has a large margin. Just eyeballed. + const offset = -15 return ( setOpenLocal(false)} - onOpen={() => setOpenLocal(aboutInfo)} + open={isHelpTooltipsVisible || isTooltipVisible} + onClose={() => setIsTooltipVisible(false)} + onOpen={() => setIsTooltipVisible(true)} title={title} describeChild placement={placement} PopperProps={{style: {zIndex: 0}}} + arrow={true} + enterDelay={1000} + slotProps={{ + popper: { + modifiers: [ + { + name: 'offset', + options: { + offset: [0, offset], + }, + }, + ], + }, + }} > setIsDialogDisplayed(!isDialogDisplayed)} + icon={icon} selected={isDialogDisplayed} variant='control' color='success' size='small' - buttonTestId={props['data-testid'] || `control-button-${title.toLowerCase()}`} + dataTestId={dataTestId || `control-button-${title.toLowerCase()}`} {...props} /> {children} @@ -127,6 +144,7 @@ export function ControlButtonWithHashState({ hashPrefix, isDialogDisplayed, setIsDialogDisplayed, + children, ...props }) { assertDefined(hashPrefix, isDialogDisplayed, setIsDialogDisplayed) @@ -150,7 +168,9 @@ export function ControlButtonWithHashState({ isDialogDisplayed={isDialogDisplayed} setIsDialogDisplayed={() => setIsDialogDisplayed(!isDialogDisplayed)} {...props} - /> + > + {children} + ) } @@ -210,6 +230,7 @@ export function FullScreenButton({onClick}) { title='Full screen' onClick={onClick} icon={} + placement='left' size='medium' /> ) @@ -226,6 +247,7 @@ export function BackButton({onClick}) { title='Back' onClick={onClick} icon={} + placement='left' size='medium' /> ) diff --git a/src/Components/Buttons.test.jsx b/src/Components/Buttons.test.jsx index e6efd7672..002ea6081 100644 --- a/src/Components/Buttons.test.jsx +++ b/src/Components/Buttons.test.jsx @@ -6,44 +6,42 @@ import {ThemeCtx} from '../theme/Theme.fixture' import QuestionIcon from '../assets/icons/Question.svg' -describe('', () => { +describe('TooltipIconButton', () => { it('should render successfully', async () => { - const buttonTestId = 'test-button' + const dataTestId = 'test-button' + const cb = jest.fn() const rendered = render( - - {}} - icon={} - placement='top' - buttonTestId={buttonTestId} - /> - ) - - const button = rendered.getByTestId(buttonTestId) - fireEvent.mouseOver(button) - - const tooltip = await rendered.findByRole('tooltip') - expect(tooltip).toBeInTheDocument() + } + onClick={cb} + placement='top' + dataTestId={dataTestId} + />, + {wrapper: ThemeCtx}) + const button = await rendered.findByTestId(dataTestId) + expect(button).toBeInTheDocument() + fireEvent.click(button) + expect(cb).toHaveBeenCalled() }) - it('show tooltip when the help is activated', async () => { const {result} = renderHook(() => useStore((state) => state)) await act(() => { result.current.setIsHelpTooltipsVisible(true) }) + const title = 'TestTooltip' + const cb = jest.fn() const {getByText} = render( - - {}} - icon={} - placement='top' - /> - ) - expect(await getByText('TestTooltip')).toBeVisible() + } + onClick={cb} + placement='top' + > + Foo + , + {wrapper: ThemeCtx}) + expect(await getByText(title)).toBeVisible() }) }) diff --git a/src/Components/Camera/hashState.js b/src/Components/Camera/hashState.js index 427f91f03..599f9f1e7 100644 --- a/src/Components/Camera/hashState.js +++ b/src/Components/Camera/hashState.js @@ -1,2 +1,16 @@ +import { + removeParamsFromHash as utilsRemoveParamsFromHash, +} from '../../utils/location' + + /** The prefix to use for the Camera state token */ export const HASH_PREFIX_CAMERA = 'c' + + +/** + * @param {string} hash + * @return {string} hash with camera params removed + */ +export function removeParamsFromHash(hash) { + return utilsRemoveParamsFromHash(hash, HASH_PREFIX_CAMERA) +} diff --git a/src/Components/ControlsGroup.jsx b/src/Components/ControlsGroup.jsx deleted file mode 100644 index 2bc752c93..000000000 --- a/src/Components/ControlsGroup.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, {ReactElement} from 'react' -import {useAuth0} from '../Auth0/Auth0Proxy' -import ButtonGroup from '@mui/material/ButtonGroup' -import NavTreeControl from './NavTree/NavTreeControl' -import OpenModelControl from './Open/OpenModelControl' -import SaveModelControl from './Open/SaveModelControl' -import SearchControl from './Search/SearchControl' -import VersionsControl from './Versions/VersionsControl' -import useStore from '../store/useStore' - - -/** - * Contains OpenModelControl, Navigate, Versions and Save - * - * @property {Function} isRepoActive deselects currently selected element - * @return {ReactElement} - */ -export default function ControlsGroup({isRepoActive}) { - const isNavTreeEnabled = useStore((state) => state.isNavTreeEnabled) - const isOpenEnabled = useStore((state) => state.isOpenEnabled) - const isSearchEnabled = useStore((state) => state.isSearchEnabled) - const {isAuthenticated} = useAuth0() - return ( - - {isOpenEnabled && - <> - - {isAuthenticated && } - } - {isNavTreeEnabled && } - {isRepoActive && } - {isSearchEnabled && } - - ) -} diff --git a/src/Components/CutPlane/CutPlaneMenu.jsx b/src/Components/CutPlane/CutPlaneMenu.jsx index fab8f70ce..082ace3d0 100644 --- a/src/Components/CutPlane/CutPlaneMenu.jsx +++ b/src/Components/CutPlane/CutPlaneMenu.jsx @@ -1,15 +1,20 @@ -import React, {ReactElement, useState, useEffect} from 'react' +import React, {ReactElement, useCallback, useEffect, useState, useRef} from 'react' import {useLocation} from 'react-router-dom' import {Vector3} from 'three' import Menu from '@mui/material/Menu' import MenuItem from '@mui/material/MenuItem' +import SvgIcon from '@mui/material/SvgIcon' import Typography from '@mui/material/Typography' import useStore from '../../store/useStore' import debug from '../../utils/debug' -import {addHashParams, getHashParams, getObjectParams, removeParams} from '../../utils/location' -import {floatStrTrim, isNumeric} from '../../utils/strings' +import {floatStrTrim} from '../../utils/strings' import {TooltipIconButton} from '../Buttons' -import {HASH_PREFIX_CUT_PLANE} from './hashState' +import { + addHashParams, + getHashParams, + getPlanesFromHash, + removeHashParams, +} from './hashState' import CloseIcon from '@mui/icons-material/Close' import CropOutlinedIcon from '@mui/icons-material/CropOutlined' import ElevationIcon from '../../assets/icons/Elevation.svg' @@ -43,27 +48,9 @@ export default function CutPlaneMenu() { debug().log('CutPlaneMenu: location: ', location) debug().log('CutPlaneMenu: cutPlanes: ', cutPlanes) - const handleClose = () => { - setAnchorEl(null) - } - - useEffect(() => { - const planeHash = getHashParams(location, HASH_PREFIX_CUT_PLANE) - debug().log('CutPlaneMenu#useEffect: planeHash: ', planeHash) - if (planeHash && model && viewer) { - const planes = getPlanes(planeHash) - debug().log('CutPlaneMenu#useEffect: planes: ', planes) - if (planes && planes.length) { - setIsCutPlaneActive(true) - planes.forEach((plane) => { - togglePlane(plane) - }) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [model]) + const handleClose = () => setAnchorEl(null) - const togglePlane = ({direction, offset = 0}) => { + const togglePlane = useCallback(({direction, offset = 0}) => { setLevelInstance(null) const modelCenter = new Vector3 model?.geometry.boundingBox.getCenter(modelCenter) @@ -74,8 +61,8 @@ export default function CutPlaneMenu() { debug().log('CutPlaneMenu#togglePlane: ifcPlanes: ', viewer.clipper.planes) if (cutPlanes.findIndex((cutPlane) => cutPlane.direction === direction) > -1) { - debug().log('CutPlaneMenu#togglePlane: found: ', true) - removeParams(HASH_PREFIX_CUT_PLANE, [direction]) + debug().log('CutPlaneMenu#togglePlane: found, removing...') + removeHashParams(location, [direction]) removeCutPlaneDirection(direction) viewer.clipper.deleteAllPlanes() const restCutPlanes = cutPlanes.filter((cutPlane) => cutPlane.direction !== direction) @@ -87,13 +74,35 @@ export default function CutPlaneMenu() { setIsCutPlaneActive(false) } } else { - debug().log('CutPlaneMenu#togglePlane: found: ', false) - addHashParams(window.location, HASH_PREFIX_CUT_PLANE, {[direction]: offset}, true) + debug().log('CutPlaneMenu#togglePlane: not found, adding...') + addHashParams(location, {[direction]: offset}) addCutPlaneDirection({direction, offset}) viewer.clipper.createFromNormalAndCoplanarPoint(normal, modelCenterOffset) setIsCutPlaneActive(true) } - } + }, [addCutPlaneDirection, cutPlanes, location, model?.geometry.boundingBox, removeCutPlaneDirection, + setIsCutPlaneActive, setLevelInstance, viewer?.clipper]) + + + const lastHashRef = useRef('') + useEffect(() => { + if (!(model && viewer)) { + return + } + + const planeHash = getHashParams(location) + if (lastHashRef.current === planeHash) { + return + } + lastHashRef.current = planeHash + const planes = getPlanesFromHash(planeHash) + if (planes && planes.length) { + setIsCutPlaneActive(true) + planes.forEach((plane) => { + togglePlane(plane) + }) + } + }, [lastHashRef, location, model, setIsCutPlaneActive, togglePlane, viewer]) return ( <> @@ -104,7 +113,7 @@ export default function CutPlaneMenu() { selected={anchorEl !== null || !!cutPlanes.length || isCutPlaneActive} variant='control' placement='top' - buttonTestId='control-button-cut-plane' + dataTestId='control-button-cut-plane' /> cutPlane.direction === 'y') > -1} data-testid='menu-item-plan' > - + Plan cutPlane.direction === 'x') > -1} data-testid='menu-item-section' > - + Section cutPlane.direction === 'z') > -1} data-testid='menu-item-elevation' > - + Elevation 0) { - const planeInfo = getPlanesOffset(viewer, ifcModel) - debug().log('CutPlaneMenu#addPlaneLocationToUrl: planeInfo: ', planeInfo) - addHashParams(window.location, HASH_PREFIX_CUT_PLANE, planeInfo, true) - } -} - - -/** - * Get offset info of x, y, z from plane hash string - * - * @param {string} planeHash - * @return {Array} - */ -export function getPlanes(planeHash) { - if (!planeHash) { - return [] - } - const parts = planeHash.split(':') - if (parts[0] !== HASH_PREFIX_CUT_PLANE || !parts[1]) { - return [] - } - const planeObjectParams = getObjectParams(planeHash) - debug().log('CutPlaneMenu#getPlanes: planeObjectParams: ', planeObjectParams) - const planes = [] - Object.entries(planeObjectParams).forEach((entry) => { - const [key, value] = entry - const removableParamKeys = [] - if (isNumeric(key)) { - removableParamKeys.push(key) - } else { - planes.push({ - direction: key, - offset: floatStrTrim(value), - }) - } - if (removableParamKeys.length) { - removeParams(HASH_PREFIX_CUT_PLANE, removableParamKeys) - } - }) - debug().log('CutPlaneMenu#getPlanes: planes: ', planes) - return planes -} - - -/** Removes cut plane params from hash state */ -export function removePlanesFromHashState() { - removeParams(HASH_PREFIX_CUT_PLANE) -} - - /** * Get plane information (normal, model center offset) * diff --git a/src/Components/CutPlane/CutPlaneMenu.test.jsx b/src/Components/CutPlane/CutPlaneMenu.test.jsx index e0456ccb1..9588744d3 100644 --- a/src/Components/CutPlane/CutPlaneMenu.test.jsx +++ b/src/Components/CutPlane/CutPlaneMenu.test.jsx @@ -5,14 +5,22 @@ import ShareMock from '../../ShareMock' import useStore from '../../store/useStore' import model from '../../__mocks__/MockModel.js' import ShareControl from '../Share/ShareControl' -import CutPlaneMenu, {getPlanes} from './CutPlaneMenu' -import {HASH_PREFIX_CUT_PLANE} from './hashState' +import CutPlaneMenu from './CutPlaneMenu' +import {HASH_PREFIX_CUT_PLANE, getPlanesFromHash} from './hashState' jest.mock('three') describe('CutPlaneMenu', () => { + beforeEach(() => { + delete global.window.location + global.window.location = { + hash: '', + } + }) + + it('Section Button', () => { const {getByTitle} = render() expect(getByTitle('Section')).toBeInTheDocument() @@ -90,29 +98,40 @@ describe('CutPlaneMenu', () => { // TODO(pablo): not sure why this is failing. Works when full stood up. - it.skip('Plane Offset is correct', async () => { + it('Plane Offset is correct', async () => { const {result} = renderHook(() => useStore((state) => state)) + const offset = 14 + const hash = `#c:-136.31,37.98,62.86,-43.48,15.73,-4.34;${HASH_PREFIX_CUT_PLANE}:y=${offset}` + const pathname = `/v/p/index.ifc${hash}` + global.window.location = { + port: '123', + protocol: 'http:', + hostname: 'localhost', + pathname: pathname, + hash: hash, + href: `http://localhost:123${pathname}`, + } const viewer = __getIfcViewerAPIExtendedMockSingleton() await act(() => { + result.current.cutPlanes = [] result.current.setViewer(viewer) result.current.setModel(model) }) - const urlSuffix = `/v/p/index.ifc#c:-136.31,37.98,62.86,-43.48,15.73,-4.34;${HASH_PREFIX_CUT_PLANE}:y=14` render( ) expect(result.current.cutPlanes[0].direction).toBe('y') - // eslint-disable-next-line no-magic-numbers - expect(result.current.cutPlanes[0].offset).toBe(14) + expect(result.current.cutPlanes[0].offset).toBe(offset) + delete global.window.location }) - it('getPlanes handles many combinations', () => { + it('getPlanesFromHash handles many combinations', () => { const check = (actualPlanes, expectPlanes) => { expect(actualPlanes.length).toBe(expectPlanes.length) for (let i = 0; i < expectPlanes.length; i++) { @@ -125,14 +144,14 @@ describe('CutPlaneMenu', () => { const pfx = HASH_PREFIX_CUT_PLANE /* eslint-disable no-magic-numbers */ - check(getPlanes(''), []) - check(getPlanes(`${pfx}:x=1`), [['x', 1]]) - check(getPlanes(`${pfx}:y=2`), [['y', 2]]) - check(getPlanes(`${pfx}:z=3`), [['z', 3]]) - check(getPlanes(`${pfx}:x=1,y=4`), [['x', 1], ['y', 4]]) - check(getPlanes(`${pfx}:x=2,z=5`), [['x', 2], ['z', 5]]) - check(getPlanes(`${pfx}:y=3,z=6`), [['y', 3], ['z', 6]]) - check(getPlanes(`${pfx}:x=0,y=1.11111,z=2.22222`), [['x', 0], ['y', 1.111], ['z', 2.222]]) + check(getPlanesFromHash(''), []) + check(getPlanesFromHash(`${pfx}:x=1`), [['x', 1]]) + check(getPlanesFromHash(`${pfx}:y=2`), [['y', 2]]) + check(getPlanesFromHash(`${pfx}:z=3`), [['z', 3]]) + check(getPlanesFromHash(`${pfx}:x=1,y=4`), [['x', 1], ['y', 4]]) + check(getPlanesFromHash(`${pfx}:x=2,z=5`), [['x', 2], ['z', 5]]) + check(getPlanesFromHash(`${pfx}:y=3,z=6`), [['y', 3], ['z', 6]]) + check(getPlanesFromHash(`${pfx}:x=0,y=1.11111,z=2.22222`), [['x', 0], ['y', 1.111], ['z', 2.222]]) /* eslint-enable no-magic-numbers */ }) }) diff --git a/src/Components/CutPlane/hashState.js b/src/Components/CutPlane/hashState.js index 623fa92cd..08f403d04 100644 --- a/src/Components/CutPlane/hashState.js +++ b/src/Components/CutPlane/hashState.js @@ -1,2 +1,105 @@ +import debug from '../../utils/debug' +import { + getObjectParams, + addHashParams as utilsAddHashParams, + getHashParams as utilsGetHashParams, + removeHashParams as utilsRemoveHashParams, +} from '../../utils/location' +import {floatStrTrim, isNumeric} from '../../utils/strings' +import {getPlanesOffset} from './CutPlaneMenu' + + /** The prefix to use for the CutPlane state token */ export const HASH_PREFIX_CUT_PLANE = 'cp' + + +/** + * @param {object} location react-router location + * @param {object} params + */ +export function addHashParams(location, params) { + utilsAddHashParams(location, HASH_PREFIX_CUT_PLANE, params, true) +} + + +/** + * @return {object} params + */ +export function getHashParams(location) { + return utilsGetHashParams(location, HASH_PREFIX_CUT_PLANE) +} + + +/** + * Removes CutPlane hash state + * + * @param {object} location react-router location + * @param {Array} paramKeys param keys to remove from + * hash params. if empty, then remove all params + */ +export function removeHashParams(location, paramKeys) { + utilsRemoveHashParams(location, HASH_PREFIX_CUT_PLANE, paramKeys) +} + + +/** + * Get offset info of x, y, z from plane hash string + * + * @param {string} planeHash + * @return {Array} + */ +export function getPlanesFromHash(planeHash) { + if (!planeHash) { + return [] + } + const parts = planeHash.split(':') + if (parts[0] !== HASH_PREFIX_CUT_PLANE || !parts[1]) { + return [] + } + const planeObjectParams = getObjectParams(planeHash) + debug().log('CutPlaneMenu#getPlanes: planeObjectParams: ', planeObjectParams) + const planes = [] + Object.entries(planeObjectParams).forEach((entry) => { + const [key, value] = entry + const removableParamKeys = [] + if (isNumeric(key)) { + removableParamKeys.push(key) + } else { + planes.push({ + direction: key, + offset: floatStrTrim(value), + }) + } + if (removableParamKeys.length) { + removeHashParams(removableParamKeys) + } + }) + debug().log('CutPlaneMenu#getPlanes: planes: ', planes) + return planes +} + + +/** + * Add plane normal and the offset to the hash state + * + * @param {object} location from react-router + * @param {object} viewer + * @param {object} ifcModel + */ +export function addPlanesToHashState(location, viewer, ifcModel) { + if (viewer.clipper.planes.length > 0) { + const planeInfo = getPlanesOffset(viewer, ifcModel) + debug().log('CutPlaneMenu#addPlaneLocationToUrl: planeInfo: ', planeInfo) + addHashParams(location, planeInfo) + } +} + + +/** + * Removes cut plane params from hash state + * + * @param {object} location From react-router + */ +export function removePlanesFromHashState(location) { + removeHashParams(location) +} diff --git a/src/Components/Dialog.jsx b/src/Components/Dialog.jsx index 4b9052506..34304c910 100644 --- a/src/Components/Dialog.jsx +++ b/src/Components/Dialog.jsx @@ -1,13 +1,9 @@ import React, {ReactElement} from 'react' -import Box from '@mui/material/Box' import Button from '@mui/material/Button' import MuiDialog from '@mui/material/Dialog' import DialogActions from '@mui/material/DialogActions' import DialogContent from '@mui/material/DialogContent' import DialogTitle from '@mui/material/DialogTitle' -import Typography from '@mui/material/Typography' -import Paper from '@mui/material/Paper' -import useTheme from '@mui/styles/useTheme' import {assertDefined} from '../utils/assert' import {CloseButton} from './Buttons' @@ -35,45 +31,25 @@ export default function Dialog({ ...props }) { assertDefined(headerText, isDialogDisplayed, setIsDialogDisplayed, children) - - const theme = useTheme() - const onCloseClick = () => setIsDialogDisplayed(false) - return ( - - {headerIcon ? - - - {headerIcon} - - {headerText} - : headerText - } - + + {headerIcon && headerIcon} + {headerText} {children} diff --git a/src/Components/ElementGroup.jsx b/src/Components/ElementsControl.jsx similarity index 100% rename from src/Components/ElementGroup.jsx rename to src/Components/ElementsControl.jsx diff --git a/src/Components/ElementGroup.test.jsx b/src/Components/ElementsControl.test.jsx similarity index 87% rename from src/Components/ElementGroup.test.jsx rename to src/Components/ElementsControl.test.jsx index b0671dce2..a810f306e 100644 --- a/src/Components/ElementGroup.test.jsx +++ b/src/Components/ElementsControl.test.jsx @@ -3,10 +3,10 @@ import {__getIfcViewerAPIExtendedMockSingleton} from 'web-ifc-viewer' import {act, render, fireEvent, renderHook} from '@testing-library/react' import ShareMock from '../ShareMock' import useStore from '../store/useStore' -import ElementGroup from './ElementGroup' +import ElementsControl from './ElementsControl' -describe('ElementGroup', () => { +describe('ElementsControl', () => { const deselectItems = jest.fn() let viewer @@ -26,7 +26,7 @@ describe('ElementGroup', () => { it('should render CutPlaneMenu component when isIsolate is false', () => { const {queryByTitle} = render( - + , ) const cutPlaneMenuButton = queryByTitle('Section') @@ -40,7 +40,7 @@ describe('ElementGroup', () => { }) const {queryByTitle} = render( - + , ) const clearButton = queryByTitle('Clear') @@ -55,7 +55,7 @@ describe('ElementGroup', () => { }) const {getByTitle} = render( - + , ) const hideButton = getByTitle('Hide') @@ -66,7 +66,7 @@ describe('ElementGroup', () => { it('should toggle the isolation mode when Isolate button is clicked', () => { const {getByTitle} = render( - + , ) const isolateButton = getByTitle('Isolate') @@ -77,7 +77,7 @@ describe('ElementGroup', () => { it('should trigger unHideAllElements when Show all button is clicked', () => { const {getByTitle} = render( - + , ) const hideButton = getByTitle('Hide') @@ -90,7 +90,7 @@ describe('ElementGroup', () => { it('should trigger hideSelectedElements when Hide button is clicked', () => { const {getByTitle} = render( - + , ) const hideButton = getByTitle('Hide') @@ -101,7 +101,7 @@ describe('ElementGroup', () => { it('should trigger deselectItems prop function when Clear button is clicked', () => { const {getByTitle} = render( - + , ) const clearButton = getByTitle('Clear') diff --git a/src/Components/Help/HelpControl.jsx b/src/Components/Help/HelpControl.jsx index 15ec55e9c..582539d48 100644 --- a/src/Components/Help/HelpControl.jsx +++ b/src/Components/Help/HelpControl.jsx @@ -3,6 +3,7 @@ import ButtonGroup from '@mui/material/ButtonGroup' import ListItem from '@mui/material/ListItem' import ListItemIcon from '@mui/material/ListItemIcon' import ListItemText from '@mui/material/ListItemText' +import SvgIcon from '@mui/material/SvgIcon' import Stack from '@mui/material/Stack' import useStore from '../../store/useStore' import {ControlButtonWithHashState, TooltipIconButton} from '../Buttons' @@ -22,11 +23,11 @@ import HideSourceOutlinedIcon from '@mui/icons-material/HideSourceOutlined' import HistoryIcon from '@mui/icons-material/History' import PortraitIcon from '@mui/icons-material/Portrait' import SearchIcon from '@mui/icons-material/Search' +import ShareIcon from '@mui/icons-material/Share' import ShiftIcon from '@mui/icons-material/FileUpload' import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined' import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined' import TreeIcon from '../../assets/icons/Tree.svg' -import ShareIcon from '../../assets/icons/Share.svg' /** @@ -50,8 +51,8 @@ export default function HelpControl() { isDialogDisplayed={isHelpVisible} setIsDialogDisplayed={setIsHelpVisible} hashPrefix={HASH_PREFIX_HELP} - placement='left' - dataTestId='help-control-button' + placement='top' + dataTestId={testId} > { , - + , @@ -201,3 +202,6 @@ const HelpList = ({pageIndex}) => { ) } + + +export const testId = 'control-button-help' diff --git a/src/Components/Help/HelpControl.test.jsx b/src/Components/Help/HelpControl.test.jsx index 42de37e82..b67ebdcd7 100644 --- a/src/Components/Help/HelpControl.test.jsx +++ b/src/Components/Help/HelpControl.test.jsx @@ -1,13 +1,13 @@ import React from 'react' import {render, fireEvent} from '@testing-library/react' import {StoreRouteThemeCtx} from '../../Share.fixture' -import HelpControl from './HelpControl' +import HelpControl, {testId} from './HelpControl' describe('HelpControl', () => { it('renders the first page of the HelpDialog', () => { - const {getByTitle, getByText} = render(, {wrapper: StoreRouteThemeCtx}) - const button = getByTitle('Help') + const {getByTestId, getByText} = render(, {wrapper: StoreRouteThemeCtx}) + const button = getByTestId(testId) fireEvent.click(button) const text = getByText('Study the model using standard sections') expect(text).toBeInTheDocument() @@ -15,7 +15,7 @@ describe('HelpControl', () => { it('navigates to the next page when the next button is clicked', () => { const {getByTestId, getByText} = render(, {wrapper: StoreRouteThemeCtx}) - const button = getByTestId('control-button-help') + const button = getByTestId(testId) fireEvent.click(button) const nextPageButton = getByTestId('Next') fireEvent.click(nextPageButton) diff --git a/src/Components/LoadingBackdrop.jsx b/src/Components/LoadingBackdrop.jsx index bc7bd6daf..e3dad1b04 100644 --- a/src/Components/LoadingBackdrop.jsx +++ b/src/Components/LoadingBackdrop.jsx @@ -1,6 +1,6 @@ import React, {ReactElement} from 'react' import Backdrop from '@mui/material/Backdrop' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import CircularProgress from '@mui/material/CircularProgress' import useStore from '../store/useStore' @@ -10,10 +10,12 @@ export default function LoadingBackdrop() { const isModelLoading = useStore((state) => state.isModelLoading) const theme = useTheme() return ( - - - ) + theme && + + + + ) } diff --git a/src/Components/Logo/Logo.jsx b/src/Components/Logo/Logo.jsx index eb331543b..383716714 100644 --- a/src/Components/Logo/Logo.jsx +++ b/src/Components/Logo/Logo.jsx @@ -1,35 +1,46 @@ import React, {ReactElement} from 'react' import Box from '@mui/material/Box' -import useTheme from '@mui/styles/useTheme' +import SvgIcon from '@mui/material/SvgIcon' +import {useTheme} from '@mui/material/styles' import LogoBIcon from '../../assets/LogoB.svg' import LogoBWithDomainIcon from '../../assets/LogoBWithDomain.svg' /** @return {ReactElement} */ -export function LogoB() { +export function LogoB({...props}) { return ( - + + + ) } /** @return {ReactElement} */ -export function LogoBWithDomain() { +export function LogoBWithDomain({...props}) { const theme = useTheme() // We're currently only showing Logo in dialogs, etc. so // use secondary contrastText return ( - + + + ) } diff --git a/src/Components/Markers/MarkerControl.jsx b/src/Components/Markers/MarkerControl.jsx index f4f96f7aa..007d92665 100644 --- a/src/Components/Markers/MarkerControl.jsx +++ b/src/Components/Markers/MarkerControl.jsx @@ -1,25 +1,26 @@ -const OAUTH_2_CLIENT_ID = process.env.OAUTH2_CLIENT_ID import PropTypes from 'prop-types' -import React, {useEffect} from 'react' -import debug from '../../utils/debug' -import {assertDefined} from '../../utils/assert' +import {memo, useEffect} from 'react' import {Vector3} from 'three' +import {assertDefined} from '../../utils/assert' +import debug from '../../utils/debug' import {roundCoord} from '../../utils/math' import {setGroupColor} from '../../utils/svg' import { - batchUpdateHash, - getHashParams, getHashParamsFromUrl, - removeParamsFromHash, - setParamsToHash, - stripHashParams, } from '../../utils/location' -import {findMarkdownUrls} from '../../utils/strings' -import {HASH_PREFIX_CAMERA} from '../../Components/Camera/hashState' import PlaceMark from '../../Infrastructure/PlaceMark' -import {HASH_PREFIX_COMMENT, HASH_PREFIX_NOTES} from '../Notes/hashState' +import { + HASH_PREFIX_COMMENT, + HASH_PREFIX_NOTES, +} from '../Notes/hashState' import useStore from '../../store/useStore' -import {HASH_PREFIX_PLACE_MARK} from './hashState' +import { + HASH_PREFIX_PLACE_MARK, + saveMarkToHash, +} from './hashState' + + +const OAUTH_2_CLIENT_ID = process.env.OAUTH2_CLIENT_ID /** @@ -32,7 +33,7 @@ import {HASH_PREFIX_PLACE_MARK} from './hashState' * @param {object} props.context The application context, typically containing configuration or state. * @param {object} props.oppositeObjects An object containing references to objects opposite to the current focus. * @param {Function} props.postProcessor A callback function for post-processing marker-related actions. - * @return {object} The MarkerControl component as a React element + * @return {null} */ function MarkerControl({context, oppositeObjects, postProcessor}) { assertDefined(context, oppositeObjects, postProcessor) @@ -83,8 +84,7 @@ function MarkerControl({context, oppositeObjects, postProcessor}) { window.location.hash = `#${selectedPlaceMarkId}` } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedPlaceMarkId, markers, forceMarkerNoteSync]) // Add markers as a dependency if it can change + }, [cameraHash, selectedPlaceMarkId, markers, forceMarkerNoteSync]) // Add markers as a dependency if it can change // Toggle visibility of all placemarks based on isNotesVisible @@ -96,8 +96,8 @@ function MarkerControl({context, oppositeObjects, postProcessor}) { placeMark.getPlacemarks().forEach((placemark_) => { placemark_.visible = isNotesVisible }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isNotesVisible]) + }, [isNotesVisible, placeMark]) + // Initialize PlaceMark instance useEffect(() => { @@ -121,104 +121,18 @@ function MarkerControl({context, oppositeObjects, postProcessor}) { } }) + // End of MarkerControl - no component returned return null } -export default React.memo(MarkerControl) - - -/** - * @param {string} urlStr - * @return {string} The transformed URL - */ -export function modifyPlaceMarkHash(hash, _issueID, _commentID) { - if (hash && _issueID) { - let newHash = hash - let newURL = null - if (!hash.startsWith('#')) { - newURL = new URL(hash) - newHash = newURL.hash - } - - if (newHash) { - newHash = removeParamsFromHash(newHash, HASH_PREFIX_NOTES) // Remove notes - newHash = removeParamsFromHash(newHash, HASH_PREFIX_COMMENT) // Remove comment - newHash = setParamsToHash(newHash, HASH_PREFIX_NOTES, {_issueID}) - - if (_commentID) { - newHash = setParamsToHash(newHash, HASH_PREFIX_COMMENT, {_commentID}) - } - } - - if (newURL) { - newURL.hash = newHash - return newURL.toString() - } - - return newHash - } - - return hash -} - - -/** - * Parses placemark URLs from an issue body. - * - * Extracts URLs that contain the specified placemark hash prefix from a given issue body. - * - * @param {string} issueBody The body of the issue to parse for placemark URLs. - * @return {string[]} An array of extracted placemark URLs. - */ -export function parsePlacemarkFromIssue(issueBody) { - return findMarkdownUrls(issueBody, HASH_PREFIX_PLACE_MARK) -} - -/** - * Retrieves the active placemark hash from the current location. - * - * Extracts the hash associated with a placemark (based on the defined hash prefix) - * from the current window's location object. - * - * @return {string|null} The active placemark hash, or `null` if no hash is found. - */ -export function getActivePlaceMarkHash() { - return getHashParams(location, HASH_PREFIX_PLACE_MARK) -} - -/** - * Extracts the placemark hash from a given URL. - * - * Parses a URL to extract the hash segment associated with a placemark, - * based on the defined hash prefix. - * - * @param {string} url The URL to parse for a placemark hash. - * @return {string|null} The extracted placemark hash, or `null` if no hash is found. - */ -export function parsePlacemarkFromURL(url) { - return getHashParamsFromUrl(url, HASH_PREFIX_PLACE_MARK) -} - -/** - * Removes placemark parameters from the URL. - * - * This function removes any URL hash parameters associated with placemarks - * (identified by the placemark hash prefix) from the current browser window's location - * or a specified location object. - * - * @param {Location|null} location The location object to modify. If null, uses `window.location`. - * @return {string} The updated hash string with placemark parameters removed. - */ -export function removeMarkerUrlParams(location = null) { - return stripHashParams(location ? location : window.location, HASH_PREFIX_PLACE_MARK) -} +export default memo(MarkerControl) /** * Place Mark Hook * - * @return {Function} + * @return {object} */ function PlacemarkHandlers() { const placeMark = useStore((state) => state.placeMark) @@ -332,14 +246,7 @@ function PlacemarkHandlers() { const normalData = roundCoord(...normal) const markArr = positionData.concat(normalData) - // Update location hash - batchUpdateHash(window.location, [ - (hash) => setParamsToHash(hash, HASH_PREFIX_PLACE_MARK, markArr), // Add placemark - (hash) => removeParamsFromHash(hash, HASH_PREFIX_CAMERA), // Remove camera - (hash) => removeParamsFromHash(hash, HASH_PREFIX_NOTES), // Remove notes - (hash) => removeParamsFromHash(hash, HASH_PREFIX_COMMENT), // Remove comment - ]) - + saveMarkToHash(markArr) // Add metadata to the temporary marker const hash = getHashParamsFromUrl(window.location.href, HASH_PREFIX_PLACE_MARK) const inactiveColor = 0xA9A9A9 diff --git a/src/Components/Markers/hashState.js b/src/Components/Markers/hashState.js index d817573c9..0203d65f2 100644 --- a/src/Components/Markers/hashState.js +++ b/src/Components/Markers/hashState.js @@ -1 +1,131 @@ +import { + batchUpdateHash, + getHashParams, + getHashParamsFromUrl, + setParamsToHash, + stripHashParams, + removeParamsFromHash as utilsRemoveParamsFromHash, +} from '../../utils/location' +import {findMarkdownUrls} from '../../utils/strings' +import {removeParamsFromHash as removeCameraParamsFromHash} from '../Camera/hashState' +import { + removeCommentParamsFromHash, + removeNotesParamsFromHash, + setCommentParamsToHash, + setNotesParamsToHash, +} from '../Notes/hashState' + + export const HASH_PREFIX_PLACE_MARK = 'm' + + +/** + * Retrieves the active placemark hash from the current location. + * + * Extracts the hash associated with a placemark (based on the defined hash prefix) + * from the current window's location object. + * + * @return {string|null} The active placemark hash, or `null` if no hash is found. + */ +export function getActivePlaceMarkHash() { + return getHashParams(location, HASH_PREFIX_PLACE_MARK) +} + + +/** + * @param {string} urlStr + * @return {string} The transformed URL + */ +export function modifyPlaceMarkHash(hash, _issueID, _commentID) { + if (hash && _issueID) { + let newHash = hash + let newURL = null + if (!hash.startsWith('#')) { + newURL = new URL(hash) + newHash = newURL.hash + } + + if (newHash) { + newHash = removeNotesParamsFromHash(newHash) + newHash = removeCommentParamsFromHash(newHash) + newHash = setNotesParamsToHash(newHash, {_issueID}) + + if (_commentID) { + newHash = setCommentParamsToHash(newHash, {_commentID}) + } + } + + if (newURL) { + newURL.hash = newHash + return newURL.toString() + } + + return newHash + } + + return hash +} + + +/** + * Parses placemark URLs from an issue body. + * + * Extracts URLs that contain the specified placemark hash prefix from a given issue body. + * + * @param {string} issueBody The body of the issue to parse for placemark URLs. + * @return {string[]} An array of extracted placemark URLs. + */ +export function parsePlacemarkFromIssue(issueBody) { + return findMarkdownUrls(issueBody, HASH_PREFIX_PLACE_MARK) +} + + +/** + * Extracts the placemark hash from a given URL. + * + * Parses a URL to extract the hash segment associated with a placemark, + * based on the defined hash prefix. + * + * @param {string} url The URL to parse for a placemark hash. + * @return {string|null} The extracted placemark hash, or `null` if no hash is found. + */ +export function parsePlacemarkFromURL(url) { + return getHashParamsFromUrl(url, HASH_PREFIX_PLACE_MARK) +} + + +/** + * Removes placemark parameters from the URL. + * + * This function removes any URL hash parameters associated with placemarks + * (identified by the placemark hash prefix) from the current browser window's location + * or a specified location object. + * + * @param {Location|null} location The location object to modify. If null, uses `window.location`. + * @return {string} The updated hash string with placemark parameters removed. + */ +export function removeMarkerUrlParams(location = null) { + return stripHashParams(location ? location : window.location, HASH_PREFIX_PLACE_MARK) +} + + +/** + * @param {string} hash + * @return {string} hash with camera params removed + */ +export function removeParamsFromHash(hash) { + return utilsRemoveParamsFromHash(hash, HASH_PREFIX_PLACE_MARK) +} + + +/** + * @param {Array} markArr + */ +export function saveMarkToHash(markArr) { + batchUpdateHash(window.location, [ + (hash) => setParamsToHash(hash, HASH_PREFIX_PLACE_MARK, markArr), + (hash) => removeCameraParamsFromHash(hash), + (hash) => removeCommentParamsFromHash(hash), + (hash) => removeNotesParamsFromHash(hash), + ]) +} diff --git a/src/Components/NavTree/NavTree.jsx b/src/Components/NavTree/NavTree.jsx index 2d3bd0c58..5280f90b2 100644 --- a/src/Components/NavTree/NavTree.jsx +++ b/src/Components/NavTree/NavTree.jsx @@ -1,4 +1,4 @@ -import React, {ReactElement, RefObject, forwardRef} from 'react' +import React, {ReactElement, forwardRef} from 'react' import {reifyName} from '@bldrs-ai/ifclib' import TreeItem from '@mui/lab/TreeItem' import useStore from '../../store/useStore' @@ -14,7 +14,6 @@ import PropTypes from './PropTypes' * @property {string} pathPrefix URL prefix for constructing links to * elements, recursively grown as passed down the tree * @property {Function} selectWithShiftClickEvents handler for shift-clicks - * @property {Map>} idToRef Mapping of expressId to TreeItem refs * @return {ReactElement} */ export default function NavTree({ @@ -23,9 +22,8 @@ export default function NavTree({ element, pathPrefix, selectWithShiftClickEvents, - idToRef, }) { - assertDefined(keyId, model, pathPrefix, selectWithShiftClickEvents, idToRef) + assertDefined(keyId, model, pathPrefix, selectWithShiftClickEvents) const navTreeItemRef = forwardRef(NavTreeItem) navTreeItemRef.propTypes = PropTypes @@ -45,7 +43,6 @@ export default function NavTree({ hasHideIcon: hasHideIcon, isExpandable: isExpandable, selectWithShiftClickEvents: selectWithShiftClickEvents, - idToRef: idToRef, }} data-testid={keyId} > @@ -60,7 +57,6 @@ export default function NavTree({ element={child} pathPrefix={pathPrefix} selectWithShiftClickEvents={selectWithShiftClickEvents} - idToRef={idToRef} /> ) }) : diff --git a/src/Components/NavTree/NavTreeControl.jsx b/src/Components/NavTree/NavTreeControl.jsx index 4e563d721..75f7b36d3 100644 --- a/src/Components/NavTree/NavTreeControl.jsx +++ b/src/Components/NavTree/NavTreeControl.jsx @@ -1,4 +1,5 @@ import React, {ReactElement} from 'react' +import SvgIcon from '@mui/material/SvgIcon' import useStore from '../../store/useStore' import {ControlButtonWithHashState} from '../Buttons' import {HASH_PREFIX_NAV_TREE} from './hashState' @@ -13,15 +14,14 @@ import TreeIcon from '../../assets/icons/Tree.svg' export default function NavTreeControl() { const isNavTreeVisible = useStore((state) => state.isNavTreeVisible) const setIsNavTreeVisible = useStore((state) => state.setIsNavTreeVisible) - return ( } + icon={} isDialogDisplayed={isNavTreeVisible} setIsDialogDisplayed={setIsNavTreeVisible} hashPrefix={HASH_PREFIX_NAV_TREE} - placement='bottom' + placement='right' /> ) } diff --git a/src/Components/NavTree/NavTreeItem.jsx b/src/Components/NavTree/NavTreeItem.jsx index 29bdca460..2d24d7d2b 100644 --- a/src/Components/NavTree/NavTreeItem.jsx +++ b/src/Components/NavTree/NavTreeItem.jsx @@ -24,7 +24,6 @@ export default function CustomContent(props, ref) { hasHideIcon, isExpandable, selectWithShiftClickEvents, - idToRef, } = props const { @@ -48,8 +47,6 @@ export default function CustomContent(props, ref) { selectWithShiftClickEvents(event.shiftKey, parseInt(nodeId)) } - idToRef[nodeId] = ref - // TODO(pablo): the following uses a measured value of 30px width for the // visiblity icon, to compute widths for a straight column layout of all of // the icons. Thifs should either be an imported value or find a better way to diff --git a/src/Components/NavTree/NavTreePanel.jsx b/src/Components/NavTree/NavTreePanel.jsx index a6509ce6d..57f2dde0f 100644 --- a/src/Components/NavTree/NavTreePanel.jsx +++ b/src/Components/NavTree/NavTreePanel.jsx @@ -1,10 +1,9 @@ -import React, {ReactElement, useEffect, useState} from 'react' +import React, {ReactElement, useState} from 'react' import TreeView from '@mui/lab/TreeView' import ToggleButton from '@mui/material/ToggleButton' import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' import Tooltip from '@mui/material/Tooltip' import {styled} from '@mui/material/styles' -import useTheme from '@mui/styles/useTheme' import useStore from '../../store/useStore' import {assertDefined} from '../../utils/assert' import Panel from '../SideDrawer/Panel' @@ -12,6 +11,7 @@ import NavTree from './NavTree' import TypesNavTree from './TypesNavTree' import AccountTreeIcon from '@mui/icons-material/AccountTree' import ListIcon from '@mui/icons-material/List' +import {removeHashParams} from './hashState' import NodeClosedIcon from '../../assets/icons/NodeClosed.svg' import NodeOpenIcon from '../../assets/icons/NodeOpened.svg' @@ -28,7 +28,7 @@ export default function NavTreePanel({ pathPrefix, selectWithShiftClickEvents, }) { - assertDefined(...arguments) + assertDefined(model, pathPrefix, selectWithShiftClickEvents) const defaultExpandedElements = useStore((state) => state.defaultExpandedElements) const defaultExpandedTypes = useStore((state) => state.defaultExpandedTypes) const elementTypesMap = useStore((state) => state.elementTypesMap) @@ -43,33 +43,26 @@ export default function NavTreePanel({ const setExpandedTypes = useStore((state) => state.setExpandedTypes) const [navigationMode, setNavigationMode] = useState('spatial-tree') - // eslint-disable-next-line react-hooks/exhaustive-deps - const idToRef = {} - const isNavTree = navigationMode === 'spatial-tree' - const theme = useTheme() - // TODO(pablo): major perf hit? - useEffect(() => { - const nodeId = selectedElements[0] - if (nodeId) { - const ref = idToRef[nodeId] - if (typeof ref?.current?.scrollIntoView === 'function') { - ref?.current?.scrollIntoView({ - block: 'center', - }) - } - } - }, [selectedElements, idToRef]) + /** Hide panel and remove hash state */ + function onClose() { + setIsNavTreeVisible(false) + removeHashParams() + } return ( setIsNavTreeVisible(false)} - action={} - sx={{m: '0 0 0 10px'}} // equal to SearchBar m:5 + p:5 + title={TITLE} + actions={ + } + onClose={onClose} + data-testid='NavTreePanel' > : - + } @@ -135,6 +125,8 @@ function Actions({navigationMode, setNavigationMode}) { }, }, })) + + return ( ) } + + +export const TITLE = 'Navigation' diff --git a/src/Components/NavTree/TypesNavTree.jsx b/src/Components/NavTree/TypesNavTree.jsx index 4e8e45e05..318e5197b 100644 --- a/src/Components/NavTree/TypesNavTree.jsx +++ b/src/Components/NavTree/TypesNavTree.jsx @@ -24,9 +24,8 @@ export default function TypesNavTree({ types, pathPrefix, selectWithShiftClickEvents, - idToRef, }) { - assertDefined(keyId, model, types, pathPrefix, selectWithShiftClickEvents, idToRef) + assertDefined(keyId, model, types, pathPrefix, selectWithShiftClickEvents) const viewer = useStore((state) => state.viewer) @@ -44,7 +43,6 @@ export default function TypesNavTree({ ContentProps={{ isExpandable: true, selectWithShiftClickEvents: selectWithShiftClickEvents, - idToRef: idToRef, }} data-testid={keyId} > @@ -62,7 +60,6 @@ export default function TypesNavTree({ hasHideIcon: hasHideIcon, isExpandable: false, selectWithShiftClickEvents: selectWithShiftClickEvents, - idToRef: idToRef, }} /> ) diff --git a/src/Components/NavTree/hashState.js b/src/Components/NavTree/hashState.js index a0c97b120..64505da12 100644 --- a/src/Components/NavTree/hashState.js +++ b/src/Components/NavTree/hashState.js @@ -1,10 +1,16 @@ -import {hasParams} from '../../utils/location' +import {hasParams, removeParams} from '../../utils/location' /** The prefix to use for the NavTree state tokens */ export const HASH_PREFIX_NAV_TREE = 'n' +/** Remove properties hash param */ +export function removeHashParams() { + removeParams(HASH_PREFIX_NAV_TREE) +} + + /** @return {boolean} */ export function isVisibleInitially() { return hasParams(HASH_PREFIX_NAV_TREE) diff --git a/src/Components/Notes/NoteCard.jsx b/src/Components/Notes/NoteCard.jsx index f5bb12619..8064e5c5b 100644 --- a/src/Components/Notes/NoteCard.jsx +++ b/src/Components/Notes/NoteCard.jsx @@ -67,7 +67,6 @@ export default function NoteCard({ const setNotes = useStore((state) => state.setNotes) const setSelectedNoteId = useStore((state) => state.setSelectedNoteId) const setSelectedNoteIndex = useStore((state) => state.setSelectedNoteIndex) - const setSelectedNote = useStore((state) => state.setSelectedNote) const setSnackMessage = useStore((state) => state.setSnackMessage) const [showCreateComment, setShowCreateComment] = useState(false) @@ -143,11 +142,6 @@ export default function NoteCard({ /** Selecting a card move the notes to the replies/comments thread. */ function selectCard() { - let selectedNote = null - if (notes) { - selectedNote = notes.filter((issue) => issue.id === id) - } - setSelectedNote(selectedNote) setSelectedNoteIndex(index) setSelectedNoteId(id) if (embeddedCameraParams) { @@ -278,7 +272,8 @@ export default function NoteCard({ } - subheader={`${username} at ${dateParts[0]} ${dateParts[1]}`} + sx={{alignItems: 'flex-start'}} + subheader={<>{username}
    {dateParts[0]} {dateParts[1]}} action={ synched && user && user.nickname === username && : } - subheader={`${username} at ${dateParts[0]} ${dateParts[1]}`} + subheader={`${username}\n${dateParts[0]} ${dateParts[1]}`} />} {isNote && !editMode && !selected && } diff --git a/src/Components/Notes/NoteCardCreate.jsx b/src/Components/Notes/NoteCardCreate.jsx index df5b5dc8f..4d7658aa7 100644 --- a/src/Components/Notes/NoteCardCreate.jsx +++ b/src/Components/Notes/NoteCardCreate.jsx @@ -8,15 +8,15 @@ import CardContent from '@mui/material/CardContent' import CardHeader from '@mui/material/CardHeader' import InputBase from '@mui/material/InputBase' import Stack from '@mui/material/Stack' -import useTheme from '@mui/styles/useTheme' -import {TooltipIconButton} from '../Buttons' +import {useTheme} from '@mui/material/styles' import useStore from '../../store/useStore' import {createIssue, getIssueComments} from '../../net/github/Issues' import {createComment} from '../../net/github/Comments' import {assertStringNotEmpty} from '../../utils/assert' -import CheckIcon from '@mui/icons-material/Check' -import PlaceMarkIcon from '../../assets/icons/PlaceMark.svg' +import {TooltipIconButton} from '../Buttons' import {PlacemarkHandlers as placemarkHandlers} from '../Markers/MarkerControl' +import CheckIcon from '@mui/icons-material/Check' +import AddLocationIcon from '@mui/icons-material/AddLocationOutlined' /** @@ -210,7 +210,7 @@ export default function NoteCardCreate({ onClick={() => { togglePlaceMarkActive(tempId) }} - icon={} + icon={} /> } diff --git a/src/Components/Notes/NoteContent.jsx b/src/Components/Notes/NoteContent.jsx index 047913ce5..1a3e249cc 100644 --- a/src/Components/Notes/NoteContent.jsx +++ b/src/Components/Notes/NoteContent.jsx @@ -2,11 +2,12 @@ import React, {ReactElement, useMemo} from 'react' import Markdown from 'react-markdown' import useStore from '../../store/useStore' import CardContent from '@mui/material/CardContent' -import {modifyPlaceMarkHash, parsePlacemarkFromURL} from '../Markers/MarkerControl' +import {modifyPlaceMarkHash, parsePlacemarkFromURL} from '../Markers/hashState' import {getHashParamsFromHashStr, getObjectParams} from '../../utils/location' import {HASH_PREFIX_CAMERA} from '../Camera/hashState' import {HASH_PREFIX_NOTES, HASH_PREFIX_COMMENT} from './hashState' + /** * @property {string} markdownContent The note text in markdown format * @return {ReactElement} diff --git a/src/Components/Notes/NoteFooter.jsx b/src/Components/Notes/NoteFooter.jsx index 5c90d9ae1..5419e85e2 100644 --- a/src/Components/Notes/NoteFooter.jsx +++ b/src/Components/Notes/NoteFooter.jsx @@ -1,21 +1,20 @@ import React, {ReactElement, useState} from 'react' import Box from '@mui/material/Box' import CardActions from '@mui/material/CardActions' +import {useTheme} from '@mui/material/styles' +import {useAuth0} from '../../Auth0/Auth0Proxy' +import {TooltipIconButton} from '../Buttons' +import {PlacemarkHandlers as placemarkHandlers} from '../Markers/MarkerControl' +import {useExistInFeature} from '../../hooks/useExistInFeature' +import useStore from '../../store/useStore' import AddCommentOutlinedIcon from '@mui/icons-material/AddCommentOutlined' +import AddLocationIcon from '@mui/icons-material/AddLocationOutlined' import CheckIcon from '@mui/icons-material/Check' import CloseIcon from '@mui/icons-material/Close' import ForumOutlinedIcon from '@mui/icons-material/ForumOutlined' import GitHubIcon from '@mui/icons-material/GitHub' -import PhotoCameraIcon from '@mui/icons-material/PhotoCamera' -import useTheme from '@mui/styles/useTheme' -import {useAuth0} from '../../Auth0/Auth0Proxy' -import {useExistInFeature} from '../../hooks/useExistInFeature' -import useStore from '../../store/useStore' -import {TooltipIconButton} from '../Buttons' -import {PlacemarkHandlers as placemarkHandlers} from '../Markers/MarkerControl' -import CameraIcon from '../../assets/icons/Camera.svg' -import PlaceMarkIcon from '../../assets/icons/PlaceMark.svg' -import ShareIcon from '../../assets/icons/Share.svg' +import PhotoCameraIcon from '@mui/icons-material/PhotoCameraOutlined' +import ShareIcon from '@mui/icons-material/Share' /** @@ -76,7 +75,6 @@ export default function NoteFooter({ placement='bottom' onClick={openGithubIssue} icon={} - aboutInfo={false} /> } @@ -86,8 +84,7 @@ export default function NoteFooter({ size='small' placement='bottom' onClick={onClickCamera} - icon={} - aboutInfo={false} + icon={} />} {selected && @@ -135,7 +132,7 @@ export default function NoteFooter({ onClick={() => { togglePlaceMarkActive(id) }} - icon={} + icon={} /> } diff --git a/src/Components/Notes/Notes.jsx b/src/Components/Notes/Notes.jsx index da7beae31..0153c9e63 100644 --- a/src/Components/Notes/Notes.jsx +++ b/src/Components/Notes/Notes.jsx @@ -8,11 +8,10 @@ import debug from '../../utils/debug' import {getIssueComments} from '../../net/github/Issues' import {getObjectParams} from '../../utils/location' import useStore from '../../store/useStore' -import {useIsMobile} from '../Hooks' import ApplicationError from '../ApplicationError' import Loader from '../Loader' import NoContent from '../NoContent' -import {parsePlacemarkFromIssue, getActivePlaceMarkHash, parsePlacemarkFromURL} from '../Markers/MarkerControl' +import {parsePlacemarkFromIssue, getActivePlaceMarkHash, parsePlacemarkFromURL} from '../Markers/hashState' import {HASH_PREFIX_NOTES, HASH_PREFIX_COMMENT} from './hashState' import NoteCard from './NoteCard' import NoteCardCreate from './NoteCardCreate' @@ -41,7 +40,6 @@ export default function Notes() { const [hasError, setHasError] = useState(false) const {user} = useAuth0() - const isMobile = useIsMobile() const selectedNote = (notes && selectedNoteId) ? @@ -56,123 +54,125 @@ export default function Notes() { setHasError(true) } + // Fetch comments based on selected note id -useEffect(() => { - (async () => { - try { - if (!repository) { - debug().warn('IssuesControl#Notes: 1, no repo defined') - return - } - if (!selectedNoteId || !selectedNote) { - return - } + useEffect(() => { + (async () => { + try { + if (!repository) { + debug().warn('IssuesControl#Notes: 1, no repo defined') + return + } + if (!selectedNoteId || !selectedNote) { + return + } - const newComments = [] - const commentMarkers = [] // Array to store markers parsed from comments - const commentArr = await getIssueComments(repository, selectedNote.number, accessToken) - debug().log('Notes#useEffect: commentArr: ', commentArr) + const newComments = [] + const commentMarkers = [] // Array to store markers parsed from comments + const commentArr = await getIssueComments(repository, selectedNote.number, accessToken) + debug().log('Notes#useEffect: commentArr: ', commentArr) - // Get the main issue marker - const issueMarker = getMarkerById(selectedNoteId) + // Get the main issue marker + const issueMarker = getMarkerById(selectedNoteId) - // Process each comment - if (commentArr) { - commentArr.forEach((comment) => { - newComments.push({ - id: comment.id, - body: comment.body, - date: comment.created_at, - username: comment.user.login, - avatarUrl: comment.user.avatar_url, - synched: true, + // Process each comment + if (commentArr) { + commentArr.forEach((comment) => { + newComments.push({ + id: comment.id, + body: comment.body, + date: comment.created_at, + username: comment.user.login, + avatarUrl: comment.user.avatar_url, + synched: true, + }) + + // Parse marker data from the comment + const commentMarker = parseComment(comment) + // Check if commentMarker is an array and has coordinates + if (Array.isArray(commentMarker) && commentMarker.length > 0) { + commentMarkers.push(...commentMarker) // Spread the array elements into commentMarkers + } }) + } - // Parse marker data from the comment - const commentMarker = parseComment(comment) - // Check if commentMarker is an array and has coordinates - if (Array.isArray(commentMarker) && commentMarker.length > 0) { - commentMarkers.push(...commentMarker) // Spread the array elements into commentMarkers - } - }) - } + // Combine the issue marker and comment markers + const hasActiveMarker = commentMarkers.some((marker) => marker.isActive) - // Combine the issue marker and comment markers - const hasActiveMarker = commentMarkers.some((marker) => marker.isActive) + if (issueMarker) { + issueMarker.isActive = !(hasActiveMarker) + } + const allMarkers = issueMarker ? [issueMarker, ...commentMarkers] : commentMarkers - if (issueMarker) { - issueMarker.isActive = !(hasActiveMarker) + // Update state with new comments and markers + setComments(newComments) + toggleSynchSidebar() + writeMarkers(allMarkers) // Assuming `setMarkers` is a function in your store or component state to update markers + } catch (e) { + debug().warn('failed to fetch comments: ', e) + handleError(e) } - const allMarkers = issueMarker ? [issueMarker, ...commentMarkers] : commentMarkers + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedNote]) - // Update state with new comments and markers - setComments(newComments) - toggleSynchSidebar() - writeMarkers(allMarkers) // Assuming `setMarkers` is a function in your store or component state to update markers - } catch (e) { - debug().warn('failed to fetch comments: ', e) - handleError(e) - } - })() - // eslint-disable-next-line react-hooks/exhaustive-deps -}, [selectedNote]) + /** + * Parses a comment to extract placemark markers. + * + * This function processes the body of a comment to extract placemark URLs, + * generates marker data, and determines their active/inactive state. + * + * @param {object} comment The comment object containing the body and metadata. + * @param {string} comment.body The text of the comment to parse for placemark URLs. + * @param {number} comment.id The unique identifier for the comment. + * @return {object[]} An array of marker objects with coordinates and other properties. + */ + function parseComment(comment) { + const inactiveColor = 0xA9A9A9 + const activeColor = 0xff0000 + const issuePlacemarkUrls = parsePlacemarkFromIssue(comment.body) + let activePlaceMarkHash = getActivePlaceMarkHash() -/** - * Parses a comment to extract placemark markers. - * - * This function processes the body of a comment to extract placemark URLs, - * generates marker data, and determines their active/inactive state. - * - * @param {object} comment The comment object containing the body and metadata. - * @param {string} comment.body The text of the comment to parse for placemark URLs. - * @param {number} comment.id The unique identifier for the comment. - * @return {object[]} An array of marker objects with coordinates and other properties. - */ -function parseComment(comment) { - const inactiveColor = 0xA9A9A9 - const activeColor = 0xff0000 - const issuePlacemarkUrls = parsePlacemarkFromIssue(comment.body) - let activePlaceMarkHash = getActivePlaceMarkHash() + // Accumulate markers for the current issue + const markers_ = issuePlacemarkUrls.map((url) => { + const hash = parsePlacemarkFromURL(url) + const newHash = `${hash};${HASH_PREFIX_NOTES}:${selectedNoteId};${HASH_PREFIX_COMMENT}:${comment.id}` + let isActive = false - // Accumulate markers for the current issue - const markers_ = issuePlacemarkUrls.map((url) => { - const hash = parsePlacemarkFromURL(url) - const newHash = `${hash};${HASH_PREFIX_NOTES}:${selectedNoteId};${HASH_PREFIX_COMMENT}:${comment.id}` - let isActive = false + const markArr = Object.values(getObjectParams(hash)) + const lastElement = markArr[5].split(';')[0] - const markArr = Object.values(getObjectParams(hash)) - const lastElement = markArr[5].split(';')[0] + if (markArr.length === 6) { + const coordinates = [ + parseFloat(markArr[0]), + parseFloat(markArr[1]), + parseFloat(markArr[2]), + parseFloat(markArr[3]), + parseFloat(markArr[4]), + parseFloat(lastElement), + ] - if (markArr.length === 6) { - const coordinates = [ - parseFloat(markArr[0]), - parseFloat(markArr[1]), - parseFloat(markArr[2]), - parseFloat(markArr[3]), - parseFloat(markArr[4]), - parseFloat(lastElement), - ] + if (activePlaceMarkHash && hash.startsWith(activePlaceMarkHash)) { + activePlaceMarkHash = newHash + isActive = true + } - if (activePlaceMarkHash && hash.startsWith(activePlaceMarkHash)) { - activePlaceMarkHash = newHash - isActive = true + return { + id: selectedNoteId, + commentId: comment.id, + coordinates: coordinates, + isActive: isActive, + activeColor: activeColor, + inactiveColor: inactiveColor, + } } + return null + }).filter(Boolean) // Filter out any null values - return { - id: selectedNoteId, - commentId: comment.id, - coordinates: coordinates, - isActive: isActive, - activeColor: activeColor, - inactiveColor: inactiveColor, - } - } - return null - }).filter(Boolean) // Filter out any null values + return markers_ // Return accumulated markers for the issue + } - return markers_ // Return accumulated markers for the issue -} /** * Gets a marker given a marker ID @@ -184,11 +184,11 @@ function parseComment(comment) { } - return hasError ? - : ( + const liSx = {paddingTop: '0px', paddingLeft: '0px', paddingRight: '0px'} + return (hasError ? : {isLoadingNotes && !isCreateNoteVisible && } @@ -198,7 +198,7 @@ function parseComment(comment) { {!selectedNoteId && !isCreateNoteVisible && notes && !isLoadingNotes && notes.map((note, index) => { return ( - + - - - )} - + + + + )} + {user && selectedNote && !selectedNote.locked && } {selectedNote && !user && - - - + + + } {selectedNote && user && selectedNote.locked && - + } {comments && selectedNote && comments.map((comment, index) => { return ( - + state.isCreateNoteVisible) + const setIsCreateNoteVisible = useStore((state) => state.setIsCreateNoteVisible) + const notes = useStore((state) => state.notes) + const selectedNoteId = useStore((state) => state.selectedNoteId) - const selectedNoteIndex = useStore((state) => state.selectedNoteIndex) - const setIsNotesVisible = useStore((state) => state.setIsNotesVisible) const setSelectedNoteId = useStore((state) => state.setSelectedNoteId) + + const selectedNoteIndex = useStore((state) => state.selectedNoteIndex) const setSelectedNoteIndex = useStore((state) => state.setSelectedNoteIndex) + const toggleIsCreateNoteVisible = useStore((state) => state.toggleIsCreateNoteVisible) const setSelectedPlaceMarkId = useStore((state) => state.setSelectedPlaceMarkId) const setSelectedPlaceMarkInNoteIdData = useStore((state) => state.setSelectedPlaceMarkInNoteIdData) @@ -38,7 +42,7 @@ export default function NotesNavBar() { const note = notes.filter((n) => n.index === index)[0] setSelectedNoteId(note.id) setSelectedNoteIndex(note.index) - setParams(HASH_PREFIX_NOTES, {id: note.id}) + setHashParams({id: note.id}) if (note.url) { setCameraFromParams(note.url) addCameraUrlParams() @@ -49,11 +53,9 @@ export default function NotesNavBar() { } - /** Hide panel and remove hash state */ - function onCloseClick() { - setIsNotesVisible(false) - removeParams(HASH_PREFIX_NOTES) - } + useEffect(() => { + setIsCreateNoteVisible(isAuthenticated) + }, [isAuthenticated, setIsCreateNoteVisible]) return ( @@ -83,13 +85,7 @@ export default function NotesNavBar() { setSelectedPlaceMarkId(null) setSelectedNoteId(null) setSelectedPlaceMarkInNoteIdData(null) - const _location = window.location - batchUpdateHash(_location, [ - (hash) => removeMarkerUrlParams({hash}), // Remove marker params - (hash) => removeParamsFromHash(hash, HASH_PREFIX_NOTES), // Remove notes params - (hash) => removeParamsFromHash(hash, HASH_PREFIX_COMMENT), // Remove comment params - (hash) => setParamsToHash(hash, HASH_PREFIX_NOTES), // Add notes params - ]) + navBackToIssue() }} icon={} variant='noBackground' @@ -131,7 +127,6 @@ export default function NotesNavBar() { alignItems: 'center', }} > - {!selectedNoteId && (isCreateNoteVisible ? - )} - ) diff --git a/src/Components/Notes/NotesPanel.jsx b/src/Components/Notes/NotesPanel.jsx index 272773f37..432a4948f 100644 --- a/src/Components/Notes/NotesPanel.jsx +++ b/src/Components/Notes/NotesPanel.jsx @@ -1,22 +1,35 @@ import React, {ReactElement} from 'react' import useStore from '../../store/useStore' -import PanelWithTitle from '../SideDrawer/PanelWithTitle' +import Panel from '../SideDrawer/Panel' import Notes from './Notes' import NotesNavBar from './NotesNavBar' +import {removeHashParams} from './hashState' +import {TITLE_NOTE, TITLE_NOTES, TITLE_NOTE_ADD} from './component' /** @return {ReactElement} */ export default function NotesPanel() { const isCreateNoteVisible = useStore((state) => state.isCreateNoteVisible) + const setIsNotesVisible = useStore((state) => state.setIsNotesVisible) const selectedNoteId = useStore((state) => state.selectedNoteId) - let title = selectedNoteId ? 'NOTE' : 'NOTES' + + + /** Hide panel and remove hash state */ + function onClose() { + setIsNotesVisible(false) + removeHashParams() + } + + + let title = selectedNoteId ? TITLE_NOTE : TITLE_NOTES if (isCreateNoteVisible) { - title = 'ADD A NOTE' + title = TITLE_NOTE_ADD } + return ( - } includeGutter={true}> + } onClose={onClose} data-testid='NotesPanel'> - + ) } diff --git a/src/Components/Notes/NotesPanel.test.jsx b/src/Components/Notes/NotesPanel.test.jsx new file mode 100644 index 000000000..ebca6f3fa --- /dev/null +++ b/src/Components/Notes/NotesPanel.test.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import {act, render, renderHook} from '@testing-library/react' +import useStore from '../../store/useStore' +import NotesPanel from './NotesPanel' +import {TITLE_NOTES} from './component' +import {RouteThemeCtx} from '../../Share.fixture' + + +describe('NotesPanel', () => { + it('renders', async () => { + const {result} = renderHook(() => useStore((state) => state)) + const {getByText} = render(, {wrapper: RouteThemeCtx}) + await act(() => { + result.current.setSelectedNoteId(null) + }) + expect(getByText(TITLE_NOTES)).toBeInTheDocument() + }) +}) diff --git a/src/Components/Notes/component.js b/src/Components/Notes/component.js new file mode 100644 index 000000000..85a9a8e1f --- /dev/null +++ b/src/Components/Notes/component.js @@ -0,0 +1,3 @@ +export const TITLE_NOTE = 'Note' +export const TITLE_NOTES = 'Notes' +export const TITLE_NOTE_ADD = 'Add a note' diff --git a/src/Components/Notes/hashState.js b/src/Components/Notes/hashState.js index e46151cf4..3c50a33a6 100644 --- a/src/Components/Notes/hashState.js +++ b/src/Components/Notes/hashState.js @@ -1,4 +1,12 @@ -import {hasParams} from '../../utils/location' +import { + hasParams, + removeParams, + removeParamsFromHash, + setParams, + setParamsToHash, + batchUpdateHash, +} from '../../utils/location' +import {removeParamsFromHash as removeMarkerParamsFromHash} from '../Markers/hashState' /** The prefix to use for the Note state tokens */ @@ -10,3 +18,66 @@ export const HASH_PREFIX_COMMENT = 'ic' export function isVisibleInitially() { return hasParams(HASH_PREFIX_NOTES) } + + +/** Removes hash params for notes and comment */ +export function removeHashParams() { + removeParams(HASH_PREFIX_NOTES) + removeParams(HASH_PREFIX_COMMENT) +} + + +/** + * @param {string} hash + * @return {string} hash with camera params removed + */ +export function removeCommentParamsFromHash(hash) { + return removeParamsFromHash(hash, HASH_PREFIX_COMMENT) +} + + +/** + * @param {string} hash + * @return {string} hash with camera params removed + */ +export function removeNotesParamsFromHash(hash) { + return removeParamsFromHash(hash, HASH_PREFIX_NOTES) +} + + +/** @param {object} params */ +export function setHashParams(params) { + setParams(HASH_PREFIX_NOTES, params) +} + + +/** + * @param {string} hash + * @param {object} params + * @return {string} hash with comments params added + */ +export function setCommentParamsToHash(hash, params) { + return setParamsToHash(hash, HASH_PREFIX_COMMENT, params) +} + + +/** + * @param {string} hash + * @param {object} params + * @return {string} hash with notes params added + */ +export function setNotesParamsToHash(hash, params) { + return setParamsToHash(hash, HASH_PREFIX_NOTES, params) +} + + +/** */ +export function navBackToIssue() { + const _location = window.location + batchUpdateHash(_location, [ + (hash) => removeMarkerParamsFromHash(hash), + (hash) => removeNotesParamsFromHash(hash), + (hash) => removeCommentParamsFromHash(hash), + (hash) => setParamsToHash(hash, HASH_PREFIX_NOTES), + ]) +} diff --git a/src/Components/Open/OpenModelControl.test.jsx b/src/Components/Open/OpenModelControl.test.jsx index 16d2613ab..f918c3229 100644 --- a/src/Components/Open/OpenModelControl.test.jsx +++ b/src/Components/Open/OpenModelControl.test.jsx @@ -8,6 +8,7 @@ import { mockedUserLoggedOut, } from '../../__mocks__/authentication' import {OpenModelControlFixture} from './OpenModelControl.fixture' +import {LABEL_GITHUB} from './component' jest.mock('../../net/github/Organizations', () => ({ @@ -21,7 +22,7 @@ describe('OpenModelControl', () => { const {getByTestId, getByText} = render() const openControlButton = getByTestId('control-button-open') fireEvent.click(openControlButton) - const GithubTab = getByText('Github') + const GithubTab = getByText(LABEL_GITHUB) fireEvent.click(GithubTab) const loginTextMatcher = (content, node) => { const hasText = (_node) => _node.textContent.includes('Host your model on GitHub and log in to Share') @@ -40,7 +41,7 @@ describe('OpenModelControl', () => { const {getByTestId, getByText} = render() const openControlButton = getByTestId('control-button-open') fireEvent.click(openControlButton) - const GithubTab = getByText('Github') + const GithubTab = getByText(LABEL_GITHUB) fireEvent.click(GithubTab) const File = getByTestId('openFile') const Repository = await getByTestId('openRepository') diff --git a/src/Components/Open/OpenModelDialog.jsx b/src/Components/Open/OpenModelDialog.jsx index 97a513b11..b00b88844 100644 --- a/src/Components/Open/OpenModelDialog.jsx +++ b/src/Components/Open/OpenModelDialog.jsx @@ -5,16 +5,17 @@ import Typography from '@mui/material/Typography' import TextField from '@mui/material/TextField' import {useAuth0} from '../../Auth0/Auth0Proxy' import {checkOPFSAvailability} from '../../OPFS/utils' +import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils' import useStore from '../../store/useStore' import {loadLocalFile, loadLocalFileFallback} from '../../utils/loader' import {disablePageReloadApprovalCheck} from '../../utils/event' import Dialog from '../Dialog' import {useIsMobile} from '../Hooks' import Tabs from '../Tabs' -import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils' import GitHubFileBrowser from './GitHubFileBrowser' import PleaseLogin from './PleaseLogin' import SampleModels from './SampleModels' +import {LABEL_LOCAL, LABEL_GITHUB, LABEL_SAMPLES} from './component' import FolderOpenIcon from '@mui/icons-material/FolderOpen' @@ -31,7 +32,7 @@ export default function OpenModelDialog({ navigate, orgNamesArr, }) { - const tabLabels = ['Local', 'Github', 'Samples'] + const tabLabels = [LABEL_LOCAL, LABEL_GITHUB, LABEL_SAMPLES] const {isAuthenticated, user} = useAuth0() const appPrefix = useStore((state) => state.appPrefix) const setCurrentTab = useStore((state) => state.setCurrentTab) diff --git a/src/Components/Open/PleaseLogin.jsx b/src/Components/Open/PleaseLogin.jsx index d08f7e822..3630574fb 100644 --- a/src/Components/Open/PleaseLogin.jsx +++ b/src/Components/Open/PleaseLogin.jsx @@ -3,7 +3,7 @@ import Divider from '@mui/material/Divider' import Paper from '@mui/material/Box' import Link from '@mui/material/Link' import Typography from '@mui/material/Typography' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' /** @return {ReactElement} */ diff --git a/src/Components/Open/component.js b/src/Components/Open/component.js new file mode 100644 index 000000000..a6f393067 --- /dev/null +++ b/src/Components/Open/component.js @@ -0,0 +1,3 @@ +export const LABEL_GITHUB = 'GitHub' +export const LABEL_LOCAL = 'Local' +export const LABEL_SAMPLES = 'Samples' diff --git a/src/Components/OperationsGroup.jsx b/src/Components/OperationsGroup.jsx deleted file mode 100644 index 82e5147ed..000000000 --- a/src/Components/OperationsGroup.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, {ReactElement} from 'react' -import ButtonGroup from '@mui/material/ButtonGroup' -import Divider from '@mui/material/Divider' -import useStore from '../store/useStore' -import {TooltipIconButton} from './Buttons' -import CameraControl from './Camera/CameraControl' -import MarkerControl from '../Components/Markers/MarkerControl' -import ImagineControl from './Imagine/ImagineControl' -import NotesControl from './Notes/NotesControl' -import ProfileControl from './Profile/ProfileControl' -import PropertiesControl from './Properties/PropertiesControl' -import ShareControl from './Share/ShareControl' -import AppStoreIcon from '../assets/icons/AppStore.svg' - - -/** - * OperationsGroup contains tools for profile, sharing, notes, properties and - * imagine - * - * @property {Function} deselectItems deselects currently selected element - * @return {ReactElement} - */ -export default function OperationsGroup({deselectItems}) { - const isAppsEnabled = useStore((state) => state.isAppsEnabled) - const isAppStoreOpen = useStore((state) => state.isAppStoreOpen) - const isImagineEnabled = useStore((state) => state.isImagineEnabled) - const isLoginEnabled = useStore((state) => state.isLoginEnabled) - const isNotesEnabled = useStore((state) => state.isNotesEnabled) - const isPropertiesEnabled = useStore((state) => state.isPropertiesEnabled) - const isShareEnabled = useStore((state) => state.isShareEnabled) - const selectedElement = useStore((state) => state.selectedElement) - const toggleAppStoreDrawer = useStore((state) => state.toggleAppStoreDrawer) - const isAnElementSelected = selectedElement !== null - - // required for MarkerControl - const viewer = useStore((state) => state.viewer) - const isModelReady = useStore((state) => state.isModelReady) - const model = useStore((state) => state.model) - - return ( - - {isLoginEnabled && ( - <> - - {/* This lines up divider with top of notes content panel */} - - )} - {isShareEnabled && } - {isNotesEnabled && } - {isPropertiesEnabled && isAnElementSelected && } - {isAppsEnabled && - } - selected={isAppStoreOpen} - onClick={() => toggleAppStoreDrawer()} - placement='left' - /> - } - {(viewer && isModelReady) && ( - - )} - {isImagineEnabled && } - {/* Invisible */} - - - ) -} diff --git a/src/Components/Profile/ProfileControl.jsx b/src/Components/Profile/ProfileControl.jsx index b53a735dc..ee60aa4d2 100644 --- a/src/Components/Profile/ProfileControl.jsx +++ b/src/Components/Profile/ProfileControl.jsx @@ -4,16 +4,16 @@ import Divider from '@mui/material/Divider' import Menu from '@mui/material/Menu' import MenuItem from '@mui/material/MenuItem' import Typography from '@mui/material/Typography' -import useTheme from '@mui/styles/useTheme' -import {TooltipIconButton} from '../Buttons' +import {useTheme} from '@mui/material/styles' import AccountBoxOutlinedIcon from '@mui/icons-material/AccountBoxOutlined' import GitHubIcon from '@mui/icons-material/GitHub' -import NightlightOutlinedIcon from '@mui/icons-material/NightlightOutlined' -import WbSunnyOutlinedIcon from '@mui/icons-material/WbSunnyOutlined' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import LoginOutlinedIcon from '@mui/icons-material/LoginOutlined' import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined' +import NightlightOutlinedIcon from '@mui/icons-material/NightlightOutlined' +import WbSunnyOutlinedIcon from '@mui/icons-material/WbSunnyOutlined' import {useAuth0} from '../../Auth0/Auth0Proxy' +import {TooltipIconButton} from '../Buttons' /** @@ -59,8 +59,8 @@ export default function ProfileControl() { } variant='control' - placement='left' - buttonTestId='control-button-profile' + placement='bottom' + dataTestId='control-button-profile' /> diff --git a/src/Components/Properties/Properties.jsx b/src/Components/Properties/Properties.jsx index 9be28b9ec..4be6a123a 100644 --- a/src/Components/Properties/Properties.jsx +++ b/src/Components/Properties/Properties.jsx @@ -1,8 +1,9 @@ import React, {ReactElement, useEffect, useState} from 'react' import {decodeIFCString} from '@bldrs-ai/ifclib' import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import useStore from '../../store/useStore' import {hexToRgba} from '../../utils/color' import {createPropertyTable} from '../../utils/itemProperties' @@ -38,23 +39,26 @@ export default function Properties() { const propSeparatorBorderOpacity = 0.3 const propSeparatorColor = hexToRgba(theme.palette.primary.contrastText, propSeparatorBorderOpacity) return ( - {propTable} {psetsList && psetsList.length > 0 && @@ -62,19 +66,16 @@ export default function Properties() { marginTop: '10px', }} > - - Property Sets + Property sets setExpandAll(!expandAll)} @@ -83,7 +84,7 @@ export default function Properties() { {psetsList} } - + ) } diff --git a/src/Components/Properties/PropertiesPanel.jsx b/src/Components/Properties/PropertiesPanel.jsx index c4d83255f..ff9e45c29 100644 --- a/src/Components/Properties/PropertiesPanel.jsx +++ b/src/Components/Properties/PropertiesPanel.jsx @@ -1,12 +1,11 @@ import React, {ReactElement, useEffect} from 'react' import {useLocation} from 'react-router' import useStore from '../../store/useStore' -import {getParams, removeParams} from '../../utils/location' -import {CloseButton} from '../Buttons' import NoContent from '../NoContent' -import PanelWithTitle from '../SideDrawer/PanelWithTitle' +import Panel from '../SideDrawer/Panel' import Properties from './Properties' -import {HASH_PREFIX_PROPERTIES} from './hashState' +import {getHashParams, removeHashParams} from './hashState' +import {TITLE} from './component' /** @@ -14,39 +13,35 @@ import {HASH_PREFIX_PROPERTIES} from './hashState' * contains the title with additional controls, and the item * properties styled container * - * @property {boolean} Include gutter Should be present only when - * Properties occupies full SideDrawer. - * @return {ReactElement} Properties Panel react component + * @return {ReactElement} */ -export default function PropertiesPanel({includeGutter}) { +export default function PropertiesPanel() { const selectedElement = useStore((state) => state.selectedElement) const setIsPropertiesVisible = useStore((state) => state.setIsPropertiesVisible) - const location = useLocation() + + /** Hide panel and remove hash state */ + function onClose() { + setIsPropertiesVisible(false) + removeHashParams() + } + + useEffect(() => { - const propsParams = getParams(location, HASH_PREFIX_PROPERTIES) + const propsParams = getHashParams(location) if (propsParams) { setIsPropertiesVisible(true) } }, [location, setIsPropertiesVisible]) - /** Hide panel and remove hash state */ - function onCloseClick() { - setIsPropertiesVisible(false) - removeParams(HASH_PREFIX_PROPERTIES) - } return ( - } - includeGutter={includeGutter} - > + {selectedElement ? : - + } - + ) } diff --git a/src/Components/Properties/PropertiesPanel.test.jsx b/src/Components/Properties/PropertiesPanel.test.jsx new file mode 100644 index 000000000..f739b89c3 --- /dev/null +++ b/src/Components/Properties/PropertiesPanel.test.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import {act, render, renderHook} from '@testing-library/react' +import {RouteThemeCtx} from '../../Share.fixture' +import useStore from '../../store/useStore' +import PropertiesPanel from './PropertiesPanel' +import {TITLE} from './component' + + +describe('PropertiesPanel', () => { + it('renders', async () => { + const {result} = renderHook(() => useStore((state) => state)) + const {getByText} = render(, {wrapper: RouteThemeCtx}) + await act(() => { + result.current.setSelectedNoteId(null) + }) + expect(getByText(TITLE)).toBeInTheDocument() + }) +}) diff --git a/src/Components/Properties/component.js b/src/Components/Properties/component.js new file mode 100644 index 000000000..63a8f9190 --- /dev/null +++ b/src/Components/Properties/component.js @@ -0,0 +1 @@ +export const TITLE = 'Properties' diff --git a/src/Components/Properties/hashState.js b/src/Components/Properties/hashState.js index 02adba9ca..4ed482d41 100644 --- a/src/Components/Properties/hashState.js +++ b/src/Components/Properties/hashState.js @@ -1,10 +1,27 @@ -import {hasParams} from '../../utils/location' +import {getParams, hasParams, removeParams} from '../../utils/location' /** The prefix to use for the Properties state token */ export const HASH_PREFIX_PROPERTIES = 'p' +/** + * Return the Properties params in the hash + * + * @param {object} location from react-router + * @return {object} Params present in state token + */ +export function getHashParams(location) { + return getParams(location, HASH_PREFIX_PROPERTIES) +} + + +/** Remove properties hash param */ +export function removeHashParams() { + removeParams(HASH_PREFIX_PROPERTIES) +} + + /** @return {boolean} */ export function isVisibleInitially() { return hasParams(HASH_PREFIX_PROPERTIES) diff --git a/src/Components/Search/SearchBar.jsx b/src/Components/Search/SearchBar.jsx index 872b8859e..d7faa540b 100644 --- a/src/Components/Search/SearchBar.jsx +++ b/src/Components/Search/SearchBar.jsx @@ -1,12 +1,11 @@ import React, {ReactElement, useRef, useEffect, useState} from 'react' import {useLocation, useNavigate, useSearchParams} from 'react-router-dom' import Autocomplete from '@mui/material/Autocomplete' -import InputAdornment from '@mui/material/InputAdornment' -import IconButton from '@mui/material/IconButton' import TextField from '@mui/material/TextField' import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils' import {disablePageReloadApprovalCheck} from '../../utils/event' import {navWithSearchParamRemoved} from '../../utils/navigate' +import {assertDefined} from '../../utils/assert' import CloseIcon from '@mui/icons-material/Close' @@ -14,23 +13,30 @@ import CloseIcon from '@mui/icons-material/Close' * The search bar doubles as an input for search queries and also open * file paths * - * @property {string} placeholder Text to display when search bar is inactive - * @property {string} helperText Text to display under the TextField + * @property {string} [placeholder] Text to display when search bar is inactive + * @property {string} [helperText] Text to display under the TextField + * @property {boolean} [isGitHubSearch] Strict screening for GH only links + * @property {Function} [onSuccess] Optional callback when search succeeds * @return {ReactElement} */ -export default function SearchBar({placeholder, helperText, id, setIsDialogDisplayed}) { +export default function SearchBar({ + placeholder = 'Search', + helperText = 'Search building or paste model link', + isGitHubSearch = false, + onSuccess = null, +}) { + assertDefined(placeholder, helperText, isGitHubSearch) const location = useLocation() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const [inputText, setInputText] = useState('') - const [gitHubSearchText, setGitHubSearchText] = useState('') const [error, setError] = useState('') const searchInputRef = useRef(null) useEffect(() => { if (location.search) { - if (id !== 'githubsearch') { + if (!isGitHubSearch) { if (validSearchQuery(searchParams)) { const newInputText = searchParams.get(QUERY_PARAM) if (inputText !== newInputText) { @@ -45,6 +51,8 @@ export default function SearchBar({placeholder, helperText, id, setIsDialogDispl setInputText('') navWithSearchParamRemoved(navigate, location.pathname, QUERY_PARAM) } + // TODO(pablo): we only care about the final state when searchParams change, + // but probably missing some validation state // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]) @@ -62,8 +70,8 @@ export default function SearchBar({placeholder, helperText, id, setIsDialogDispl const modelPath = githubUrlOrPathToSharePath(inputText) disablePageReloadApprovalCheck() navigate(modelPath, {replace: true}) - if (setIsDialogDisplayed) { - setIsDialogDisplayed(false) + if (onSuccess) { + onSuccess() } } catch (e) { setError(`Please enter a valid url. Click on the LINK icon to learn more.`) @@ -81,67 +89,38 @@ export default function SearchBar({placeholder, helperText, id, setIsDialogDispl }) } else { setSearchParams({q: inputText}) - setIsDialogDisplayed(true) + onSuccess() } searchInputRef.current.blur() } - const handleKeyDown = (event) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault() - onSubmit(event) - } - } - - // The container and paper are set to 100% width to fill the // container SearchBar shares with NavTreePanel. This is an easier // way to have them share the same width, which is now set in the // parent container (CadView). return ( -
    + - (id === 'githubsearch') ? setGitHubSearchText(newValue || '') : setInputText(newValue || '')} - onInputChange={(_, newInputValue) => - (id === 'githubsearch') ? setGitHubSearchText(newInputValue || '') : setInputText(newInputValue || '')} + options={['Dach', 'Decke', 'Fen', 'Wand', 'Leuchte', 'Pos', 'Te']} + value={inputText} + onChange={(_, newValue) => setInputText(newValue || '')} + onInputChange={(_, newInputValue) => setInputText(newInputValue || '')} clearIcon={} - inputValue={(id === 'githubsearch') ? gitHubSearchText : inputText} + inputValue={inputText} renderInput={(params) => ( - (id === 'githubsearch') ? setGitHubSearchText('') : setInputText('')} - sx={{height: '2em', width: '2em'}} - > - - - - ), - }} /> )} /> diff --git a/src/Components/Share/ShareControl.jsx b/src/Components/Share/ShareControl.jsx index 596e765ba..2e79542ab 100644 --- a/src/Components/Share/ShareControl.jsx +++ b/src/Components/Share/ShareControl.jsx @@ -1,6 +1,7 @@ import React, {ReactElement, createRef, useEffect, useState} from 'react' import {Helmet} from 'react-helmet-async' import QRCode from 'react-qr-code' +import {useLocation} from 'react-router' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import InputAdornment from '@mui/material/InputAdornment' @@ -10,13 +11,12 @@ import Typography from '@mui/material/Typography' import useStore from '../../store/useStore' import {ControlButtonWithHashState} from '../Buttons' import {addCameraUrlParams, removeCameraUrlParams} from '../Camera/CameraControl' -import {addPlanesToHashState, removePlanesFromHashState} from '../CutPlane/CutPlaneMenu' +import {addPlanesToHashState, removePlanesFromHashState} from '../CutPlane/hashState' import Dialog from '../Dialog' import Toggle from '../Toggle' import {HASH_PREFIX_SHARE} from './hashState' import ContentCopyIcon from '@mui/icons-material/ContentCopy' -import ShareIcon from '@mui/icons-material/Share' -import CopyIcon from '../../assets/icons/Copy.svg' +import ShareIcon from '@mui/icons-material/ShareOutlined' /** @@ -36,7 +36,7 @@ export default function ShareControl() { isDialogDisplayed={isShareVisible} setIsDialogDisplayed={setIsShareVisible} hashPrefix={HASH_PREFIX_SHARE} - placement='left' + placement='bottom' > { - if (viewer && isDialogDisplayed) { + if (viewer?.clipper && isDialogDisplayed) { if (isCameraInUrl) { addCameraUrlParams(cameraControls) } else { @@ -77,11 +78,11 @@ function ShareDialog({isDialogDisplayed, setIsDialogDisplayed}) { if (isCutPlaneActive) { setIsPlaneInUrl(true) - addPlanesToHashState(viewer, model) + addPlanesToHashState(location, viewer, model) } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewer, model, isDialogDisplayed]) + }, [cameraControls, isCameraInUrl, isCutPlaneActive, isDialogDisplayed, location, model, viewer, viewer?.clipper]) + const onCopy = (event) => { setIsLinkCopied(true) @@ -89,6 +90,7 @@ function ShareDialog({isDialogDisplayed, setIsDialogDisplayed}) { urlTextFieldRef.current.select() } + const toggleCameraIncluded = () => { if (isCameraInUrl) { setIsCameraInUrl(false) @@ -102,11 +104,12 @@ function ShareDialog({isDialogDisplayed, setIsDialogDisplayed}) { } } + const togglePlaneIncluded = () => { if (isPlaneInUrl) { - removePlanesFromHashState() + removePlanesFromHashState(location) } else { - addPlanesToHashState(viewer, model) + addPlanesToHashState(location, viewer, model) } setIsPlaneInUrl(!isPlaneInUrl) } @@ -119,7 +122,7 @@ function ShareDialog({isDialogDisplayed, setIsDialogDisplayed}) { isDialogDisplayed={isDialogDisplayed} setIsDialogDisplayed={setIsDialogDisplayed} actionTitle='Copy Link' - actionIcon={} + actionIcon={} actionCb={onCopy} > diff --git a/src/Components/Share/hashState.js b/src/Components/Share/hashState.js index e2283d365..a63b9bf54 100644 --- a/src/Components/Share/hashState.js +++ b/src/Components/Share/hashState.js @@ -1,7 +1,16 @@ +import {removeParams} from '../../utils/location' + + /** The prefix to use for the Share state token */ export const HASH_PREFIX_SHARE = 'share' +/** Remove share hash param */ +export function removeHashParams() { + removeParams(HASH_PREFIX_SHARE) +} + + /** @return {boolean} */ export function isVisibleInitially() { return false diff --git a/src/Components/SideDrawer/HorizonResizerButton.jsx b/src/Components/SideDrawer/HorizonResizerButton.jsx index d0c59c396..098109622 100644 --- a/src/Components/SideDrawer/HorizonResizerButton.jsx +++ b/src/Components/SideDrawer/HorizonResizerButton.jsx @@ -1,30 +1,28 @@ -import React, {useEffect, useState, useCallback, useRef} from 'react' +import React, {ReactElement, useEffect, useState, useCallback, useRef} from 'react' import {useDoubleTap} from 'use-double-tap' -import useStore from '../../store/useStore' import Box from '@mui/material/Box' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import {isNumber} from '../../utils/strings' /** * Grab button to for resizing SideDrawer horizontally. * - * @property {useRef} sidebarRef sidebar ref object. - * @property {Function} setSidebarWidth sidebar width changing button. + * @property {useRef} drawerRef drawer ref object. + * @property {Function} setDrawerWidth drawer width changing button. * @property {number} thickness resizer thickness in pixels. * @property {boolean} isOnLeft resizer is on the left. - * @property {string} sidebarWidth sidebar width (...px, ...vw). - * @return {React.Component} + * @property {string} drawerWidth drawer width (...px, ...vw). + * @return {ReactElement} */ export default function HorizonResizerButton({ - sidebarRef, + drawerRef, thickness = 100, isOnLeft = true, + drawerWidth, + drawerWidthInitial, + setDrawerWidth, }) { - const sidebarWidth = useStore((state) => state.sidebarWidth) - const sidebarWidthInitial = useStore((state) => state.sidebarWidthInitial) - const setSidebarWidth = useStore((state) => state.setSidebarWidth) - const [isResizing, setIsResizing] = useState(false) const [isExpanded, setIsExpanded] = useState(false) @@ -36,60 +34,51 @@ export default function HorizonResizerButton({ const gripSize = thickness * gripButtonRatio const horizonPadding = (thickness - gripSize) / 2 - const startResizing = useCallback(() => { - setIsResizing(true) - }, []) - - - const stopResizing = useCallback(() => { - setIsResizing(false) - }, []) - - - const onResizerDblTap = useDoubleTap((e) => { - setIsExpanded(!isExpanded) - }) - + const startResizing = useCallback(() => setIsResizing(true), []) + const stopResizing = useCallback(() => setIsResizing(false), []) + const onResizerDblTap = useDoubleTap((e) => setIsExpanded(!isExpanded)) const half = 0.5 const resize = useCallback( - (mouseMoveEvent) => { - if (isResizing) { - if (isOnLeft) { - expansionSidebarWidth = - sidebarRef.current.getBoundingClientRect().right - - mouseMoveEvent.clientX + - (thickness * half) - } else { - expansionSidebarWidth = - mouseMoveEvent.clientX - - sidebarRef.current.getBoundingClientRect().left - - (thickness * half) - } - if (expansionSidebarWidth < 0) { - expansionSidebarWidth = 0 - } - if (expansionSidebarWidth > window.innerWidth) { - expansionSidebarWidth = window.innerWidth - } - if (expansionSidebarWidth < thickness) { - expansionSidebarWidth = thickness - } - setSidebarWidth(expansionSidebarWidth) - setIsExpanded(true) + (mouseMoveEvent) => { + let expansionDrawerWidth = window.innerWidth + if (isResizing) { + if (isOnLeft) { + expansionDrawerWidth = + drawerRef.current.getBoundingClientRect().right - + mouseMoveEvent.clientX + + (thickness * half) + } else { + expansionDrawerWidth = + mouseMoveEvent.clientX - + drawerRef.current.getBoundingClientRect().left - + (thickness * half) } - }, - [isResizing, isOnLeft, setSidebarWidth, sidebarRef, thickness], + if (expansionDrawerWidth < 0) { + expansionDrawerWidth = 0 + } + if (expansionDrawerWidth > window.innerWidth) { + expansionDrawerWidth = window.innerWidth + } + if (expansionDrawerWidth < thickness) { + expansionDrawerWidth = thickness + } + setDrawerWidth(expansionDrawerWidth) + setIsExpanded(true) + } + }, + [isResizing, isOnLeft, setDrawerWidth, drawerRef, thickness], ) useEffect(() => { + let expansionDrawerWidth = window.innerWidth const onWindowResize = (e) => { - if (e.target.innerWidth < expansionSidebarWidth) { - expansionSidebarWidth = e.target.innerWidth + if (e.target.innerWidth < expansionDrawerWidth) { + expansionDrawerWidth = e.target.innerWidth } - if (e.target.innerWidth < sidebarWidth) { - setSidebarWidth(e.target.innerWidth) + if (e.target.innerWidth < drawerWidth) { + setDrawerWidth(e.target.innerWidth) } } window.addEventListener('resize', onWindowResize) @@ -100,7 +89,7 @@ export default function HorizonResizerButton({ window.removeEventListener('mousemove', resize) window.removeEventListener('mouseup', stopResizing) } - }, [resize, setSidebarWidth, sidebarWidth, stopResizing]) + }, [resize, setDrawerWidth, drawerWidth, stopResizing]) useEffect(() => { @@ -142,20 +131,22 @@ export default function HorizonResizerButton({ resizer.removeEventListener('touchend', onTouchEnd) resizer.removeEventListener('touchmove', onTouchMove) } - }, [resize, setSidebarWidth, sidebarWidth, startResizing, stopResizing]) + }, [resize, setDrawerWidth, drawerWidth, startResizing, stopResizing]) + // Double-click on resizer switches to previous width useEffect(() => { + const expansionDrawerWidth = window.innerWidth if (isExpanded) { - setSidebarWidth(expansionSidebarWidth) + setDrawerWidth(expansionDrawerWidth) } else { - const defaultWidth = - isNumber(sidebarWidthInitial) ? - Math.min(window.innerWidth, sidebarWidthInitial) : - sidebarWidthInitial - setSidebarWidth(defaultWidth) + const width = + isNumber(drawerWidthInitial) ? + Math.min(window.innerWidth, drawerWidthInitial) : + drawerWidthInitial + setDrawerWidth(width) } - }, [isExpanded, sidebarWidthInitial, setSidebarWidth]) + }, [isExpanded, drawerWidthInitial, setDrawerWidth]) return ( @@ -188,7 +179,7 @@ export default function HorizonResizerButton({ cursor: 'col-resize', }} ref={resizerRef} - data-testid="x_resizer" + data-testid='x_resizer' onMouseDown={startResizing} {...onResizerDblTap} > @@ -208,6 +199,3 @@ export default function HorizonResizerButton({ ) } - - -let expansionSidebarWidth = window.innerWidth diff --git a/src/Components/SideDrawer/Panel.jsx b/src/Components/SideDrawer/Panel.jsx index f2182eb83..9dfd7562e 100644 --- a/src/Components/SideDrawer/Panel.jsx +++ b/src/Components/SideDrawer/Panel.jsx @@ -1,9 +1,7 @@ import React, {ReactElement} from 'react' import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' -import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' -import useTheme from '@mui/styles/useTheme' import {assertDefined} from '../../utils/assert' import {CloseButton} from '../Buttons' import {useIsMobile} from '../Hooks' @@ -13,65 +11,79 @@ import {useIsMobile} from '../Hooks' * A panel component with a sticky header containing a title and close button * * @property {string|ReactElement} title The title to display in the panel header - * @property {Function} onCloseClick A callback to be executed when the close button is clicked + * @property {Function} onClose A callback to be executed when the close button is clicked * @property {ReactElement} children Enclosed elements - * @property {ReactElement} [action] Action component, for the top bar - * @property {object} [sx] Passed to root Paper elt - * @property {string} [paperTestId] Set on the root Paper element + * @property {ReactElement} [actions] Actions component, for the top bar + * @property {string} [data-testid] Set on the root Paper element * @return {ReactElement} */ -export default function Panel({title, onCloseClick, children, action = null, sx = {}, paperTestId = ''}) { - assertDefined(title, onCloseClick, children, paperTestId) - const theme = useTheme() +export default function Panel({title, onClose, children, actions = null, ...props}) { + assertDefined(title, onClose, children) + return ( + + + + {children} + + + ) +} + + +/** + * @property {string} title Panel title + * @property {Function} onClose Callback for close + * @property {object} [actions] Actions component placed to the right of the title + * @return {ReactElement} + */ +function PanelTitle({title, onClose, actions}) { + assertDefined(title, onClose) const isMobile = useIsMobile() return ( - - {title}} + - { - typeof(title) === 'string' ? - - {title} - : - <>{title} - } - - {action} - {/* TODO(pablo): maybe a better place for this */} - {!isMobile && } - - - {children} - + {actions} + {!isMobile && } + + ) } + + +const TITLE_HEIGHT = '60px' diff --git a/src/Components/SideDrawer/PanelTitle.jsx b/src/Components/SideDrawer/PanelTitle.jsx deleted file mode 100644 index 94819cdc3..000000000 --- a/src/Components/SideDrawer/PanelTitle.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, {ReactElement} from 'react' -import Box from '@mui/material/Box' -import Typography from '@mui/material/Typography' -import {assertDefined} from '../../utils/assert' - - -/** - * @property {string} title Panel title - * @property {object} [controlsGroup] Controls Group is placed on the right of the title - * @property {string} [iconSrc] url to an image to be used to prepend and icon to the title - * @return {ReactElement} - */ -export default function PanelTitle({title, controlsGroup, iconSrc}) { - assertDefined(title) - return ( - - - {iconSrc ? - {title} : <> - } - - {title} - - - {controlsGroup} - - ) -} diff --git a/src/Components/SideDrawer/PanelWithTitle.jsx b/src/Components/SideDrawer/PanelWithTitle.jsx deleted file mode 100644 index c1065a72c..000000000 --- a/src/Components/SideDrawer/PanelWithTitle.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, {ReactElement} from 'react' -import Box from '@mui/material/Box' -import {assertDefined} from '../../utils/assert' -import {useIsMobile} from '../Hooks' -import PanelTitle from './PanelTitle' - - -/** - * @property {string} title Panel title - * @property {ReactElement} children Panel content - * @property {ReactElement} [controlsGroup] Controls in title bar - * @property {string} [iconSrc] url to an image to be used to prepend and icon to the title - * @return {ReactElement} - */ -export default function PanelWithTitle({title, children, controlsGroup, iconSrc}) { - assertDefined(title, children) - const titleHeight = '2.8em' - // This isn't visible, but the alignment is important for debugging, so leaving. - const isMobile = useIsMobile() - - return ( - - - - {children} - - - ) -} diff --git a/src/Components/SideDrawer/SideDrawer.jsx b/src/Components/SideDrawer/SideDrawer.jsx index e81678e18..ccf958d10 100644 --- a/src/Components/SideDrawer/SideDrawer.jsx +++ b/src/Components/SideDrawer/SideDrawer.jsx @@ -1,13 +1,10 @@ import React, {ReactElement, useRef} from 'react' import Box from '@mui/material/Box' -import Divider from '@mui/material/Divider' import Paper from '@mui/material/Paper' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import {useIsMobile} from '../Hooks' -import NotesPanel from '../Notes/NotesPanel' -import PropertiesPanel from '../Properties/PropertiesPanel' import useStore from '../../store/useStore' -import {hexToRgba} from '../../utils/color' +import {assertDefined} from '../../utils/assert' import HorizonResizerButton from './HorizonResizerButton' import VerticalResizerButton from './VerticalResizerButton' @@ -15,42 +12,50 @@ import VerticalResizerButton from './VerticalResizerButton' /** * Container for Notes and Properties * + * @property {boolean} isDrawerVisible State toggle for drawer state + * @property {number} drawerWidth In pixels + * @property {number} drawerWidthInitial In pixels + * @property {Function} setDrawerWidth In pixels + * @property {boolean} [isResizeOnLeft] Position of the resize handle. Default: true + * @property {string} [dataTestId] data-testid tag + * @property {Array} children Drawer content * @return {ReactElement} */ -export default function SideDrawer() { - const isNotesVisible = useStore((state) => state.isNotesVisible) - const isPropertiesVisible = useStore((state) => state.isPropertiesVisible) - const sidebarHeight = useStore((state) => state.sidebarHeight) - const sidebarWidth = useStore((state) => state.sidebarWidth) - +export default function SideDrawer({ + isDrawerVisible, + drawerWidth, + drawerWidthInitial, + setDrawerWidth, + children, + isResizeOnLeft = true, + dataTestId = 'SideDrawer', +}) { + assertDefined(isDrawerVisible, drawerWidth, drawerWidthInitial, setDrawerWidth, isResizeOnLeft, dataTestId, children) + // Only one bottom drawer, so accessed here instead of passed in + const drawerHeight = useStore((state) => state.drawerHeight) + const drawerHeightInitial = useStore((state) => state.drawerHeightInitial) + const setDrawerHeight = useStore((state) => state.setDrawerHeight) const isMobile = useIsMobile() const theme = useTheme() - - const sidebarRef = useRef(null) - - const thickness = 10 - const isDrawerOpen = isNotesVisible === true || isPropertiesVisible === true - const isDividerVisible = isNotesVisible && isPropertiesVisible - const borderOpacity = 0.5 - const borderColor = hexToRgba(theme.palette.secondary.contrastText, borderOpacity) - - // TODO(pablo): removed what looked to be notes useEffect here. + const drawerRef = useRef(null) + const resizeButtonThickness = 10 + const resizeMargin = isResizeOnLeft ? '0 0 0 1em' : '0 1em 0 0' return ( - {!isMobile && } - {isMobile && } - {/* Content */} + {!isMobile && + } + {isMobile && + } - - {isNotesVisible && } - - {isDividerVisible && } - - {isPropertiesVisible && } - + {children} diff --git a/src/Components/SideDrawer/SideDrawer.test.jsx b/src/Components/SideDrawer/SideDrawer.test.jsx index 325bfb03d..bdcf1e4f4 100644 --- a/src/Components/SideDrawer/SideDrawer.test.jsx +++ b/src/Components/SideDrawer/SideDrawer.test.jsx @@ -1,60 +1,26 @@ import React from 'react' -import {act, render, renderHook, fireEvent} from '@testing-library/react' +import {act, render, renderHook} from '@testing-library/react' import useStore from '../../store/useStore' -import ShareMock from '../../ShareMock' -import {useIsMobile} from '../Hooks' +import {ThemeCtx} from '../../theme/Theme.fixture' import SideDrawer from './SideDrawer' describe('SideDrawer', () => { - it('notes', async () => { + it('renders', async () => { const {result} = renderHook(() => useStore((state) => state)) - const {findByText} = render() await act(() => { - result.current.toggleIsNotesVisible() - result.current.setIsSideDrawerVisible(true) + result.current.setSelectedNoteId(null) }) + const {findByText} = render( + + NOTES + , + {wrapper: ThemeCtx}) expect(await findByText('NOTES')).toBeVisible() - - // reset the store - await act(() => { - result.current.setIsNotesVisible(false) - }) - }) - - it('properties', async () => { - const {result} = renderHook(() => useStore((state) => state)) - const {findByText} = render() - await act(() => { - result.current.setIsPropertiesVisible(true) - result.current.setIsSideDrawerVisible(true) - }) - expect(await findByText('PROPERTIES')).toBeVisible() - - // reset the store - await act(() => { - result.current.setSelectedElement({}) - result.current.toggleIsPropertiesVisible() - }) - }) - - it('mobile vertical resizing', async () => { - const mobileHook = renderHook(() => useIsMobile()) - const storeHook = renderHook(() => useStore((state) => state)) - const sideDrawerRender = render() - await act(() => { - storeHook.result.current.toggleIsNotesVisible() - storeHook.result.current.setIsSideDrawerVisible(true) - }) - expect(await sideDrawerRender.findByText('NOTES')).toBeVisible() - expect(mobileHook.result.current).toBe(false) - const sidebarWidthInitial = storeHook.result.current.sidebarWidthInitial - const xResizerEl = sideDrawerRender.getByTestId('x_resizer') - fireEvent.click(xResizerEl) - fireEvent.click(xResizerEl) - expect(storeHook.result.current.sidebarWidth).toBe(window.innerWidth) - fireEvent.click(xResizerEl) - fireEvent.click(xResizerEl) - expect(storeHook.result.current.sidebarWidth).toBe(sidebarWidthInitial) }) }) diff --git a/src/Components/SideDrawer/SideDrawerPanels.test.jsx b/src/Components/SideDrawer/SideDrawerPanels.test.jsx deleted file mode 100644 index afa16b604..000000000 --- a/src/Components/SideDrawer/SideDrawerPanels.test.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import {act, render, renderHook} from '@testing-library/react' -import ShareMock from '../../ShareMock' -import useStore from '../../store/useStore' -import PropertiesPanel from '../Properties/PropertiesPanel' -import NotesPanel from '../Notes/NotesPanel' - - -describe('SideDrawerPanels', () => { - it('Notes', async () => { - const {result} = renderHook(() => useStore((state) => state)) - const {getByText} = render() - await act(() => { - result.current.setSelectedNoteId(null) - }) - expect(getByText('NOTES')).toBeInTheDocument() - }) - - it('Properties', () => { - const {getByText} = render() - expect(getByText('PROPERTIES')).toBeInTheDocument() - }) -}) diff --git a/src/Components/SideDrawer/VerticalResizerButton.jsx b/src/Components/SideDrawer/VerticalResizerButton.jsx index ac6018f68..c3d612f5e 100644 --- a/src/Components/SideDrawer/VerticalResizerButton.jsx +++ b/src/Components/SideDrawer/VerticalResizerButton.jsx @@ -1,8 +1,8 @@ -import React, {useEffect, useState, useCallback, useRef} from 'react' +import React, {ReactElement, useEffect, useState, useCallback, useRef} from 'react' import {useDoubleTap} from 'use-double-tap' import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import useStore from '../../store/useStore' import {isNumber} from '../../utils/strings' @@ -10,19 +10,19 @@ import {isNumber} from '../../utils/strings' /** * Grab button to for resizing SideDrawer vertically. * - * @property {useRef} sidebarRef sidebar ref object. + * @property {useRef} drawerRef drawer ref object. * @property {number} thickness resizer thickness in pixels. * @property {boolean} isOnTop resizer is on the top. - * @return {React.Component} + * @return {ReactElement} */ export default function VerticalResizerButton({ - sidebarRef, + drawerRef, thickness = 10, isOnTop = true, }) { - const sidebarHeight = useStore((state) => state.sidebarHeight) - const sidebarHeightInitial = useStore((state) => state.sidebarHeightInitial) - const setSidebarHeight = useStore((state) => state.setSidebarHeight) + const drawerHeight = useStore((state) => state.drawerHeight) + const drawerHeightInitial = useStore((state) => state.drawerHeightInitial) + const setDrawerHeight = useStore((state) => state.setDrawerHeight) const [isResizing, setIsResizing] = useState(false) const [isExpanded, setIsExpanded] = useState(false) @@ -34,60 +34,51 @@ export default function VerticalResizerButton({ const gripButtonRatio = 0.5 const gripSize = thickness * gripButtonRatio - const startResizing = useCallback(() => { - setIsResizing(true) - }, []) - - - const stopResizing = useCallback(() => { - setIsResizing(false) - }, []) - - - const onResizerDblTap = useDoubleTap((e) => { - setIsExpanded(!isExpanded) - }) - + const startResizing = useCallback(() => setIsResizing(true), []) + const stopResizing = useCallback(() => setIsResizing(false), []) + const onResizerDblTap = useDoubleTap((e) => setIsExpanded(!isExpanded)) const half = 0.5 const resize = useCallback( - (mouseMoveEvent) => { - if (isResizing) { - if (isOnTop) { - expansionSidebarHeight = - sidebarRef.current.getBoundingClientRect().bottom - - mouseMoveEvent.clientY + - (thickness * half) - } else { - expansionSidebarHeight = - mouseMoveEvent.clientX - - sidebarRef.current.getBoundingClientRect().top - - (thickness * half) - } - if (expansionSidebarHeight < 0) { - expansionSidebarHeight = 0 - } - if (expansionSidebarHeight > window.innerHeight) { - expansionSidebarHeight = window.innerHeight - } - if (expansionSidebarHeight < thickness) { - expansionSidebarHeight = thickness - } - setSidebarHeight(expansionSidebarHeight) - setIsExpanded(true) + (mouseMoveEvent) => { + let expansionDrawerHeight = window.innerHeight + if (isResizing) { + if (isOnTop) { + expansionDrawerHeight = + drawerRef.current.getBoundingClientRect().bottom - + mouseMoveEvent.clientY + + (thickness * half) + } else { + expansionDrawerHeight = + mouseMoveEvent.clientX - + drawerRef.current.getBoundingClientRect().top - + (thickness * half) + } + if (expansionDrawerHeight < 0) { + expansionDrawerHeight = 0 } - }, - [isResizing, isOnTop, setSidebarHeight, sidebarRef, thickness], + if (expansionDrawerHeight > window.innerHeight) { + expansionDrawerHeight = window.innerHeight + } + if (expansionDrawerHeight < thickness) { + expansionDrawerHeight = thickness + } + setDrawerHeight(expansionDrawerHeight) + setIsExpanded(true) + } + }, + [isResizing, isOnTop, setDrawerHeight, drawerRef, thickness], ) useEffect(() => { + let expansionDrawerHeight = window.innerHeight const onWindowResize = (e) => { - if (e.target.innerHeight < expansionSidebarHeight) { - expansionSidebarHeight = e.target.innerHeight + if (e.target.innerHeight < expansionDrawerHeight) { + expansionDrawerHeight = e.target.innerHeight } - if (e.target.innerHeight < sidebarHeight) { - setSidebarHeight(e.target.innerHeight) + if (e.target.innerHeight < drawerHeight) { + setDrawerHeight(e.target.innerHeight) } } window.addEventListener('resize', onWindowResize) @@ -98,7 +89,7 @@ export default function VerticalResizerButton({ window.removeEventListener('mousemove', resize) window.removeEventListener('mouseup', stopResizing) } - }, [resize, setSidebarHeight, sidebarHeight, stopResizing]) + }, [resize, setDrawerHeight, drawerHeight, stopResizing]) useEffect(() => { @@ -140,20 +131,21 @@ export default function VerticalResizerButton({ resizer.removeEventListener('touchend', onTouchEnd) resizer.removeEventListener('touchmove', onTouchMove) } - }, [resize, setSidebarHeight, sidebarHeight, startResizing, stopResizing]) + }, [resize, setDrawerHeight, drawerHeight, startResizing, stopResizing]) useEffect(() => { + const expansionDrawerHeight = window.innerHeight if (isExpanded) { - setSidebarHeight(expansionSidebarHeight) + setDrawerHeight(expansionDrawerHeight) } else { const defaultHeight = - isNumber(sidebarHeightInitial) ? - Math.min(window.innerHeight, sidebarHeightInitial) : - sidebarHeightInitial - setSidebarHeight(defaultHeight) + isNumber(drawerHeightInitial) ? + Math.min(window.innerHeight, drawerHeightInitial) : + drawerHeightInitial + setDrawerHeight(defaultHeight) } - }, [isExpanded, setSidebarHeight, sidebarHeightInitial]) + }, [isExpanded, setDrawerHeight, drawerHeightInitial]) return ( @@ -182,7 +174,7 @@ export default function VerticalResizerButton({ sx={{ width: '150px', paddingTop: `10px`, - paddingBottom: '40px', + paddingBottom: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -207,6 +199,3 @@ export default function VerticalResizerButton({ ) } - - -let expansionSidebarHeight = window.innerHeight diff --git a/src/Components/TabbedDialog.fixture.jsx b/src/Components/TabbedDialog.fixture.jsx deleted file mode 100644 index db86c4ea4..000000000 --- a/src/Components/TabbedDialog.fixture.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' -import debug from '../utils/debug' -import {ThemeCtx} from '../theme/Theme.fixture' -import TabbedDialog from './TabbedDialog' - - -const loremIpsum = (size) => `Lorem ipsum dolor sit amet, consectetur adipiscing elit, - sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. `.repeat(size) - - -export default ( - - {loremIpsum(3)}

    ), - (

    {loremIpsum(2)}

    ), - (

    {loremIpsum(4)}

    ), - ]} - actionCbs={[ - () => debug().log('clicked 1'), - () => debug().log('clicked 2'), - () => debug().log('clicked 3'), - ]} - isDialogDisplayed={true} - setIsDialogDisplayed={() => debug().log('setIsDialogDisplayed')} - /> -
    -) diff --git a/src/Components/TabbedDialog.jsx b/src/Components/TabbedDialog.jsx deleted file mode 100644 index 730e22827..000000000 --- a/src/Components/TabbedDialog.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, {ReactElement, useState} from 'react' -import MuiDialog from '@mui/material/Dialog' -import DialogActions from '@mui/material/DialogActions' -import DialogContent from '@mui/material/DialogContent' -import DialogTitle from '@mui/material/DialogTitle' -import {assertDefined, assertArraysEqualLength} from '../utils/assert' -import {CloseButton, RectangularButton} from './Buttons' -import Tabs from './Tabs' - - -/** - * A Dialog with tabs to page between associated contents. - * - * @property {Array} tabLabels Tab names - * @property {Array} headerLabels Short messages describing the current operation - * @property {Array} contentComponents Components coresponding to the tabs - * @property {Array} actionCbs Callbacks for each component's ok button - * @property {boolean} isDialogDisplayed React var - * @property {Function} setIsDialogDisplayed React setter - * @property {boolean} [isTabsScrollable] Activate if the number of tabs is larger than 5 - * @property {ReactElement} [icon] Leading icon above header description - * @property {string} [actionButtonLabels] Labels for action ok buttons - * @return {ReactElement} - */ -export default function TabbedDialog({ - tabLabels, - headerLabels, - contentComponents, - actionCbs, - isDialogDisplayed, - setIsDialogDisplayed, - isTabsScrollable = false, - icon, - actionButtonLabels, -}) { - assertDefined(tabLabels, headerLabels, contentComponents, actionCbs, isDialogDisplayed, setIsDialogDisplayed) - assertArraysEqualLength(tabLabels, headerLabels, contentComponents, actionCbs) - const onClose = () => setIsDialogDisplayed(false) - const [currentTab, setCurrentTab] = useState(0) - return ( - - - - {icon && <>{icon}
    } - {headerLabels[currentTab]} -
    - - - - - {contentComponents[currentTab]} - - - - -
    - ) -} diff --git a/src/Components/TabbedDialog.test.jsx b/src/Components/TabbedDialog.test.jsx deleted file mode 100644 index ad5968395..000000000 --- a/src/Components/TabbedDialog.test.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' -import {render, screen, fireEvent} from '@testing-library/react' -import TabbedDialog from './TabbedDialog' - - -describe('TabbedDialog', () => { - it('', () => { - const cb1 = jest.fn() - const cb2 = jest.fn() - const cb3 = jest.fn() - render( - {'A content'}

    ), - (

    {'B content'}

    ), - (

    {'C content'}

    ), - ]} - actionCbs={[cb1, cb2, cb3]} - actionButtonLabels={['A OK', 'B OK', 'C OK']} - isDialogDisplayed={true} - setIsDialogDisplayed={jest.fn()} - />) - - fireEvent.click(screen.getByText('A OK')) - expect(cb1.mock.calls.length).toBe(1) - expect(cb2.mock.calls.length).toBe(0) - expect(cb3.mock.calls.length).toBe(0) - - fireEvent.click(screen.getByText('Open')) - fireEvent.click(screen.getByText('B OK')) - expect(cb1.mock.calls.length).toBe(1) - expect(cb2.mock.calls.length).toBe(1) - expect(cb3.mock.calls.length).toBe(0) - - fireEvent.click(screen.getByText('Save')) - fireEvent.click(screen.getByText('C OK')) - expect(cb1.mock.calls.length).toBe(1) - expect(cb2.mock.calls.length).toBe(1) - expect(cb3.mock.calls.length).toBe(1) - }) -}) diff --git a/src/Components/Versions/VersionsControl.jsx b/src/Components/Versions/VersionsControl.jsx index f62d57f06..725b87ac4 100644 --- a/src/Components/Versions/VersionsControl.jsx +++ b/src/Components/Versions/VersionsControl.jsx @@ -2,6 +2,7 @@ import React, {ReactElement} from 'react' import useStore from '../../store/useStore' import {ControlButtonWithHashState} from '../Buttons' import {HASH_PREFIX_VERSIONS} from './hashState' +import {TITLE} from './VersionsPanel' import HistoryIcon from '@mui/icons-material/History' @@ -15,12 +16,12 @@ export default function VersionsControl() { const setIsVersionsVisible = useStore((state) => state.setIsVersionsVisible) return ( } isDialogDisplayed={isVersionsVisible} setIsDialogDisplayed={setIsVersionsVisible} hashPrefix={HASH_PREFIX_VERSIONS} - placement='bottom' + placement='right' /> ) } diff --git a/src/Components/Versions/VersionsPanel.fixture.js b/src/Components/Versions/VersionsPanel.fixture.js index d69666ac7..0de5e2899 100644 --- a/src/Components/Versions/VersionsPanel.fixture.js +++ b/src/Components/Versions/VersionsPanel.fixture.js @@ -2,10 +2,17 @@ const RAW_GIT_PROXY_URL_NEW = process.env.RAW_GIT_PROXY_URL_NEW export const MOCK_MODEL_PATH_GIT = { - org: 'user2', + orgName: 'user2', repo: 'Schneestock-Public', branch: 'main', filepath: '/ZGRAGGEN.ifc', eltPath: '', gitpath: `${RAW_GIT_PROXY_URL_NEW}/user2/Schneestock-Public/main/ZGRAGGEN.ifc`, + getRepoPath: () => '/main/blob/ZGRAGGEN.ifc', +} + + +export const MOCK_REPOSITORY = { + orgName: 'testOrg', + name: 'testRepo', } diff --git a/src/Components/Versions/VersionsPanel.jsx b/src/Components/Versions/VersionsPanel.jsx index 8ce2a2067..049bd87dc 100644 --- a/src/Components/Versions/VersionsPanel.jsx +++ b/src/Components/Versions/VersionsPanel.jsx @@ -1,16 +1,18 @@ -import React, {ReactElement, useState, useEffect} from 'react' +import React, {ReactElement} from 'react' import {useNavigate} from 'react-router-dom' import useStore from '../../store/useStore' -import {getCommitsForFile} from '../../net/github/Commits' import {assertDefined} from '../../utils/assert' -import debug from '../../utils/debug' import {navigateBaseOnModelPath} from '../../utils/location' import {TooltipIconButton} from '../Buttons' import Panel from '../SideDrawer/Panel' import VersionsTimeline from './VersionsTimeline' +import useVersions from './useVersions' import RestartAltIcon from '@mui/icons-material/RestartAlt' +export const TITLE = 'Versions' + + /** * VersionsPanel displays a series of versions in a timeline format. * Each version corresponds to a commit, and this component fetches @@ -22,37 +24,12 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt' */ export default function VersionsPanel({filePath, currentRef}) { assertDefined(filePath, currentRef) + const navigate = useNavigate() const accessToken = useStore((state) => state.accessToken) const repository = useStore((state) => state.repository) const modelPath = useStore((state) => state.modelPath) const setIsVersionsVisible = useStore((state) => state.setIsVersionsVisible) - - const [commitData, setCommitData] = useState([]) - - const navigate = useNavigate() - - useEffect(() => { - const fetchCommits = async () => { - try { - const commits = await getCommitsForFile(repository, filePath, accessToken) - if (commits) { - const versionsInfo = commits.map((entry) => { - const extractedData = { - authorName: entry.commit.author.name, - commitMessage: entry.commit.message, - commitDate: entry.commit.author.date, - sha: entry.sha, - } - return extractedData - }) - setCommitData(versionsInfo) - } - } catch (error) { - debug().log(error) - } - } - fetchCommits() - }, [repository, filePath, accessToken]) + const {commits, loading, error} = useVersions({accessToken, repository, filePath}) /** @@ -61,7 +38,7 @@ export default function VersionsPanel({filePath, currentRef}) { * @param {number} index active commit index */ function navigateToCommit(index) { - const sha = commitData[index].sha + const sha = commits[index].sha if (modelPath) { const commitPath = navigateBaseOnModelPath(modelPath.org, modelPath.repo, sha, modelPath.filepath) @@ -80,11 +57,10 @@ export default function VersionsPanel({filePath, currentRef}) { } } - return ( } @@ -93,15 +69,18 @@ export default function VersionsPanel({filePath, currentRef}) { size='small' /> } - sx={{m: '0 0 0 10px'}} // equal to SearchBar m:5 + p:5 - onCloseClick={() => setIsVersionsVisible(false)} - data-testid='Version Panel' + onClose={() => setIsVersionsVisible(false)} + data-testid='VersionsPanel' > - + <> + {loading && <>Loading...} + {error && <>Error: error} + + ) } diff --git a/src/Components/Versions/VersionsPanel.test.jsx b/src/Components/Versions/VersionsPanel.test.jsx index 5d0c94389..0b665ca33 100644 --- a/src/Components/Versions/VersionsPanel.test.jsx +++ b/src/Components/Versions/VersionsPanel.test.jsx @@ -1,23 +1,69 @@ import React from 'react' -import {render, renderHook, act} from '@testing-library/react' -import ShareMock from '../../ShareMock' +import {act, render, renderHook, waitFor} from '@testing-library/react' +import {StoreRouteThemeCtx} from '../../Share.fixture' import useStore from '../../store/useStore' +import VersionsPanel, {TITLE} from './VersionsPanel' import { MOCK_MODEL_PATH_GIT, MOCK_REPOSITORY, } from './VersionsPanel.fixture' -import VersionsPanel from './VersionsPanel' +import useVersions from './useVersions' +import {MOCK_COMMITS} from './VersionsTimeline.fixture' + + +jest.mock('./useVersions') describe('VersionsPanel', () => { it('renders the panel', async () => { + // Simulated hook state + let mockCommitsState = { + commits: [], + loading: true, + } + + // Mock useCommits to return the current state + useVersions.mockImplementation(() => mockCommitsState) + + // Also setup store state const {result} = renderHook(() => useStore((state) => state)) await act(() => { + result.current.setAccessToken('') + result.current.setIsVersionsVisible(true) + result.current.setRepository(MOCK_REPOSITORY.orgName, MOCK_REPOSITORY.name) result.current.setModelPath(MOCK_MODEL_PATH_GIT) - result.current.setRepository(MOCK_REPOSITORY) }) - const {getByText} = render(, {wrapper: ShareMock}) - const dialogTitle = getByText('Versions') - expect(dialogTitle).toBeInTheDocument() + + // Render the component + const {getByText, rerender} = render( + , + {wrapper: StoreRouteThemeCtx}, + ) + + // Ensure loading state is rendered initially + await waitFor(() => { + expect(getByText('Versions')).toBeInTheDocument() + }) + + // Transition the state + mockCommitsState = { + commits: MOCK_COMMITS, + loading: false, + } + + // Re-render to simulate the state update + rerender( + , + {wrapper: StoreRouteThemeCtx}) + + // Wait for the updated state to be rendered + await waitFor(() => { + expect(getByText(TITLE)).toBeInTheDocument() + }) + MOCK_COMMITS.forEach((commit) => { + expect(getByText(commit.authorName)).toBeInTheDocument() + expect(getByText(commit.commitDate)).toBeInTheDocument() + expect(getByText(commit.commitMessage)).toBeInTheDocument() + }) }) }) diff --git a/src/Components/Versions/Timeline.fixture.jsx b/src/Components/Versions/VersionsTimeline.fixture.jsx similarity index 52% rename from src/Components/Versions/Timeline.fixture.jsx rename to src/Components/Versions/VersionsTimeline.fixture.jsx index d0b1dcdf0..734022df7 100644 --- a/src/Components/Versions/Timeline.fixture.jsx +++ b/src/Components/Versions/VersionsTimeline.fixture.jsx @@ -3,21 +3,25 @@ import {ThemeCtx} from '../../theme/Theme.fixture' import VersionsTimeline from './VersionsTimeline' -const commitData = [ - {authorName: 'Version1', +export const MOCK_COMMITS = [ + { + authorName: 'testAuthor1', commitDate: '09.17.2023', commitMessage: 'commit 1', }, - {authorName: 'Version1', - commitDate: '09.17.2023', + { + authorName: 'testAuthor2', + commitDate: '09.18.2023', commitMessage: 'commit 2', }, - {authorName: 'Version1', - commitDate: '09.17.2023', + { + authorName: 'testAuthor3', + commitDate: '09.19.2023', commitMessage: 'commit 3', }, - {authorName: 'Version1', - commitDate: '09.17.2023', + { + authorName: 'testAuthor4', + commitDate: '09.20.2023', commitMessage: 'commit 4', }, ] @@ -25,6 +29,6 @@ const commitData = [ export default ( - + ) diff --git a/src/Components/Versions/VersionsTimeline.jsx b/src/Components/Versions/VersionsTimeline.jsx index 97dffd099..06e955aa1 100644 --- a/src/Components/Versions/VersionsTimeline.jsx +++ b/src/Components/Versions/VersionsTimeline.jsx @@ -1,15 +1,15 @@ import React, {ReactElement, useState, useEffect} from 'react' import Timeline from '@mui/lab/Timeline' -import TimelineDot from '@mui/lab/TimelineDot' -import TimelineItem from '@mui/lab/TimelineItem' import TimelineConnector from '@mui/lab/TimelineConnector' import TimelineContent from '@mui/lab/TimelineContent' -import TimelineSeparator from '@mui/lab/TimelineSeparator' +import TimelineDot from '@mui/lab/TimelineDot' +import TimelineItem from '@mui/lab/TimelineItem' import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent' +import TimelineSeparator from '@mui/lab/TimelineSeparator' import Paper from '@mui/material/Paper' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import {styled} from '@mui/system' import Loader from '../Loader' import NoContent from '../NoContent' @@ -21,34 +21,34 @@ import CommitIcon from '@mui/icons-material/Commit' * Each version corresponds to a commit, and this component fetches * commit data for the provided branch and displays it. * - * @property {Array} commitData An array of commits + * @property {Array} commits An array of commits * @property {string} currentRef To indicate as active in the UI * @property {Function} commitNavigateCb A callback function to navigate to a specific commit * @return {ReactElement} */ -export default function VersionsTimeline({commitData, currentRef, commitNavigateCb}) { +export default function VersionsTimeline({commits, currentRef, commitNavigateCb}) { const [showLoginMessage, setShowLoginMessage] = useState(false) const timeoutMillis = 4000 useEffect(() => { - // Set a timeout to display the login message after 4 seconds if commitData is still empty + // Set a timeout to display the login message after 4 seconds if commits is still empty const timer = setTimeout(() => { - if (commitData.length === 0) { + if (commits.length === 0) { setShowLoginMessage(true) } }, timeoutMillis) - // Clear the timeout if commitData is populated or the component unmounts + // Clear the timeout if commits is populated or the component unmounts return () => clearTimeout(timer) - }, [commitData]) + }, [commits]) const shaLength = 40 const refIsSha = currentRef.length === shaLength return ( - - {commitData.length === 0 && !showLoginMessage && } + + {commits.length === 0 && !showLoginMessage && } {showLoginMessage && ( )} - {commitData.map((commit, i) => ( + {commits.map((commit, i) => ( commitNavigateCb(i)}> { - const mockCommitData = [ - { - authorName: 'User1', - commitDate: '2023-10-13', - commitMessage: 'Create initial structure', - }, - { - authorName: 'User2', - commitDate: '2023-10-14', - commitMessage: 'Add new feature', - }, - ] - - it('displays the correct number of timeline items', () => { + it('displays the correct timeline items', () => { const commitNavigateCb = jest.fn() const {getByText} = render( ) - const firstItem = getByText('User1') - const secondItem = getByText('User2') - expect(firstItem).toBeInTheDocument() - expect(secondItem).toBeInTheDocument() + MOCK_COMMITS.forEach((commit) => { + expect(getByText(commit.authorName)).toBeInTheDocument() + expect(getByText(commit.commitDate)).toBeInTheDocument() + expect(getByText(commit.commitMessage)).toBeInTheDocument() + }) }) it('updates the active timeline item on click', () => { @@ -40,12 +29,12 @@ describe('CustomTimeline', () => { const {getByText} = render( ) - const firstItem = getByText('User1') + const firstItem = getByText(MOCK_COMMITS[0].authorName) fireEvent.click(firstItem) expect(commitNavigateCb).toHaveBeenCalledTimes(1) }) diff --git a/src/Components/Versions/useVersions.jsx b/src/Components/Versions/useVersions.jsx new file mode 100644 index 000000000..08683e2b2 --- /dev/null +++ b/src/Components/Versions/useVersions.jsx @@ -0,0 +1,50 @@ +import {useEffect, useState} from 'react' +import {useAuth0} from '../../Auth0/Auth0Proxy' +import {getCommitsForFile} from '../../net/github/Commits' +import {assertDefined} from '../../utils/assert' + + +/** @return {object} */ +export default function useVersions({repository, filePath, accessToken}) { + assertDefined(accessToken, repository.orgName, repository.name, filePath) + + const {isAuthenticated} = useAuth0() + + const [commits, setCommits] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchCommits = async () => { + if (isAuthenticated && accessToken === '') { + // TODO(pablo): seen these in dev + console.warn('Unauthed flow while user is logged-in') + return + } + setLoading(true) + try { + const gitCommits = await getCommitsForFile(repository, filePath, accessToken) + if (gitCommits) { + const extractedCommits = gitCommits.map((entry) => { + const extractedCommit = { + authorName: entry.commit.author.name, + commitMessage: entry.commit.message, + commitDate: entry.commit.author.date, + sha: entry.sha, + } + return extractedCommit + }) + setCommits(extractedCommits) + } + } catch (e) { + setError(e) + console.error(e) + } finally { + setLoading(false) + } + } + fetchCommits() + }, [accessToken, filePath, isAuthenticated, repository]) + + return {commits, loading, error} +} diff --git a/src/Components/Versions/useVersions.test.jsx b/src/Components/Versions/useVersions.test.jsx new file mode 100644 index 000000000..1e7985265 --- /dev/null +++ b/src/Components/Versions/useVersions.test.jsx @@ -0,0 +1,21 @@ +import {renderHook, waitFor} from '@testing-library/react' +import useVersions from './useVersions' +import {MOCK_COMMITS} from '../../net/github/Commits.fixture' + + +describe('useVersions', () => { + it('fetches and returns commits', async () => { + const {result} = renderHook(() => useVersions(TEST_PARAMS)) + await waitFor(() => expect(result.current.loading).toBe(true)) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.commits.length).toEqual(MOCK_COMMITS.length) + expect(result.current.error).toBe(null) + }) +}) + + +const TEST_PARAMS = { + repository: {name: 'testrepo', orgName: 'testowner'}, + filePath: '', + accessToken: '', +} diff --git a/src/Containers/AppsSideDrawer.jsx b/src/Containers/AppsSideDrawer.jsx new file mode 100644 index 000000000..f9f077862 --- /dev/null +++ b/src/Containers/AppsSideDrawer.jsx @@ -0,0 +1,49 @@ +import React, {ReactElement} from 'react' +import Box from '@mui/material/Box' +import AppsPanel, {AppPreviewPanel} from '../Components/Apps/AppsPanel' +import SideDrawer from '../Components/SideDrawer/SideDrawer' +import useStore from '../store/useStore' + + +/** + * @return {ReactElement} + */ +export default function AppsSideDrawer() { + const isAppsVisible = useStore((state) => state.isAppsVisible) + const appsDrawerWidth = useStore((state) => state.appsDrawerWidth) + const appsDrawerWidthInitial = useStore((state) => state.appsDrawerWidthInitial) + const setAppsDrawerWidth = useStore((state) => state.setAppsDrawerWidth) + const selectedApp = useStore((state) => state.selectedApp) + return ( + + + + {!selectedApp ? + : + + } + + + + ) +} diff --git a/src/Containers/BottomBar.jsx b/src/Containers/BottomBar.jsx new file mode 100644 index 000000000..cdc782fb4 --- /dev/null +++ b/src/Containers/BottomBar.jsx @@ -0,0 +1,28 @@ +import React, {ReactElement} from 'react' +import Stack from '@mui/material/Stack' +import AboutControl from '../Components/About/AboutControl' +import ElementsControl from '../Components/ElementsControl' +import HelpControl from '../Components/Help/HelpControl' + + +/** + * BottomBar contains AboutControl, ElementsControl and HelpControl + * + * @property {Function} deselectItems deselects currently selected element + * @return {ReactElement} + */ +export default function BottomBar({deselectItems}) { + return ( + + + + + + ) +} diff --git a/src/Containers/CadView.jsx b/src/Containers/CadView.jsx index f9a78b76b..8c6766e01 100644 --- a/src/Containers/CadView.jsx +++ b/src/Containers/CadView.jsx @@ -2,16 +2,12 @@ import React, {ReactElement, useEffect, useState} from 'react' import {useNavigate, useSearchParams, useLocation} from 'react-router-dom' import {MeshLambertMaterial} from 'three' import Box from '@mui/material/Box' -import useTheme from '@mui/styles/useTheme' +import {useTheme} from '@mui/material/styles' import {filetypeRegex} from '../Filetype' import {useAuth0} from '../Auth0/Auth0Proxy' -import AboutControl from '../Components/About/AboutControl' import {onHash} from '../Components/Camera/CameraControl' import {resetState as resetCutPlaneState} from '../Components/CutPlane/CutPlaneMenu' -import ElementGroup from '../Components/ElementGroup' -import HelpControl from '../Components/Help/HelpControl' import {useIsMobile} from '../Components/Hooks' -import LoadingBackdrop from '../Components/LoadingBackdrop' import {load} from '../loader/Loader' import * as Analytics from '../privacy/analytics' import useStore from '../store/useStore' @@ -23,9 +19,7 @@ import {groupElementsByTypes} from '../utils/ifc' import {navWith} from '../utils/navigate' import {setKeydownListeners} from '../utils/shortcutKeys' import Picker from '../view/Picker' -import AlertDialogAndSnackbar from './AlertDialogAndSnackbar' -import ControlsGroupAndDrawer from './ControlsGroupAndDrawer' -import OperationsGroupAndDrawer from './OperationsGroupAndDrawer' +import RootLandscape from './RootLandscape' import ViewerContainer from './ViewerContainer' import {elementSelection} from './selection' import {partsToPath} from './urls' @@ -52,7 +46,8 @@ export default function CadView({ const accessToken = useStore((state) => state.accessToken) const customViewSettings = useStore((state) => state.customViewSettings) const elementTypesMap = useStore((state) => state.elementTypesMap) - const isDrawerOpen = useStore((state) => state.isDrawerOpen) + const isAppsVisible = useStore((state) => state.isAppsVisible) + const isNotesVisible = useStore((state) => state.isNotesVisible) const preselectedElementIds = useStore((state) => state.preselectedElementIds) const searchIndex = useStore((state) => state.searchIndex) const selectedElements = useStore((state) => state.selectedElements) @@ -409,7 +404,7 @@ export default function CadView({ setLevelInstance(null) resetSelection() - resetCutPlaneState(viewer, setCutPlaneDirections, setIsCutPlaneActive) + resetCutPlaneState(location, viewer, setCutPlaneDirections, setIsCutPlaneActive) setIsSearchBarVisible(false) setIsNavTreeVisible(false) setIsPropertiesVisible(false) @@ -542,7 +537,7 @@ export default function CadView({ // ModelPath changes in parent (ShareRoutes) from user and // programmatic navigation (e.g. clicking element links). useEffect(() => { - debug().log('CadView#useEffect1[modelPath], calling onModelPath...') + debug().log('CadView#useEffect1[modelPath], calling onModelPath, modelPath:', modelPath) onModelPath() }, [modelPath, customViewSettings]) @@ -623,50 +618,35 @@ export default function CadView({ // looking at. // TODO(pablo): add render testing useEffect(() => { + const isDrawerOpen = isNotesVisible || isAppsVisible if (viewer && !isMobile) { - viewer.container.style.width = isDrawerOpen ? `calc(100% - ${sidebarWidth})` : '100%' + viewer.container.style.width = isDrawerOpen ? `calc(100vw - ${sidebarWidth}px)` : '100vw' viewer.context.resize() } - }, [isDrawerOpen, isMobile, viewer, sidebarWidth]) + }, [isNotesVisible, isAppsVisible, isMobile, viewer, sidebarWidth]) const abs = {position: 'absolute'} const absTop = {top: 0, ...abs} - const absBtm = {bottom: 0, ...abs} - const center = {left: '50%', transform: 'translate(-50%)'} // TODO(pablo): need to set the height on the row stack below to keep them // from expanding return ( - + {} - - {viewer && ( - <> - { - elementSelection(viewer, elementsById, selectItemsInScene, isShiftKeyDown, expressId) - }} - /> - - - - - - - - - + { + elementSelection(viewer, elementsById, selectItemsInScene, isShiftKeyDown, expressId) + }} + deselectItems={deselectItems} + /> )} - - ) } diff --git a/src/Containers/CadView.test.jsx b/src/Containers/CadView.test.jsx index a22fb8035..8c5d8d634 100644 --- a/src/Containers/CadView.test.jsx +++ b/src/Containers/CadView.test.jsx @@ -5,6 +5,7 @@ import * as Ifc from '@bldrs-ai/ifclib' import {render, renderHook, act, fireEvent, screen, waitFor, within} from '@testing-library/react' import * as Filetype from '../Filetype' import ShareMock from '../ShareMock' +import {testId as aboutControlTestId} from '../Components/About/AboutControl' import {HASH_PREFIX_CUT_PLANE} from '../Components/CutPlane/hashState' import {HASH_PREFIX_CAMERA} from '../Components/Camera/hashState' import {IfcViewerAPIExtended} from '../Infrastructure/IfcViewerAPIExtended' @@ -14,11 +15,9 @@ import * as Loader from '../loader/Loader' import {makeTestTree} from '../utils/TreeUtils.test' import {actAsyncFlush} from '../utils/tests' import CadView from './CadView' -import PkgJson from '../../package.json' window.HTMLElement.prototype.scrollIntoView = jest.fn() -const bldrsVersionString = `Bldrs: ${PkgJson.version}` const mockedUseNavigate = jest.fn() const defaultLocationValue = {pathname: '/index.ifc', search: '', hash: '', state: null, key: 'default'} // mock createObjectURL @@ -153,7 +152,7 @@ describe('CadView', () => { // Necessary to wait for some of the component to render to avoid // act() warnings from testing-library. await actAsyncFlush() - await waitFor(() => screen.getByTitle(bldrsVersionString)) + await waitFor(() => screen.getByTestId(aboutControlTestId)) }) @@ -165,7 +164,7 @@ describe('CadView', () => { await act(() => result.current.setModelPath({filepath: `/index.ifc`})) render() await actAsyncFlush() - await waitFor(() => screen.getByTitle(bldrsVersionString)) + await waitFor(() => screen.getByTestId(aboutControlTestId)) const getPropsCalls = viewer.getProperties.mock.calls const numCallsExpected = 3 // First for root, second from URL path @@ -196,7 +195,7 @@ describe('CadView', () => { // Necessary to wait for some of the component to render to avoid // act() warnings from testing-library. await actAsyncFlush() - await waitFor(() => screen.getByTitle(bldrsVersionString)) + await waitFor(() => screen.getByTestId(aboutControlTestId)) // Identify the drop zone element using the cadview-dropzone attribute const dropZone = screen.getByTestId('cadview-dropzone') @@ -292,7 +291,7 @@ describe('CadView', () => { , ) await actAsyncFlush() - await waitFor(() => screen.getByTitle(bldrsVersionString)) + await waitFor(() => screen.getByTestId(aboutControlTestId)) await actAsyncFlush() render() diff --git a/src/Containers/ControlsGroup.jsx b/src/Containers/ControlsGroup.jsx new file mode 100644 index 000000000..85b7fe54a --- /dev/null +++ b/src/Containers/ControlsGroup.jsx @@ -0,0 +1,49 @@ +import React, {ReactElement} from 'react' +import Stack from '@mui/material/Stack' +import {useAuth0} from '../Auth0/Auth0Proxy' +import NavTreeControl from '../Components/NavTree/NavTreeControl' +import OpenModelControl from '../Components/Open/OpenModelControl' +import SaveModelControl from '../Components/Open/SaveModelControl' +import SearchBar from '../Components/Search/SearchBar' +import SearchControl from '../Components/Search/SearchControl' +import VersionsControl from '../Components/Versions/VersionsControl' +import useStore from '../store/useStore' + + +/** + * Contains OpenModelControl, Navigate, Versions and Save + * + * @return {ReactElement} + */ +export default function ControlsGroup() { + const isNavTreeEnabled = useStore((state) => state.isNavTreeEnabled) + const isVersionsEnabled = useStore((state) => state.isVersionsEnabled) + const isOpenEnabled = useStore((state) => state.isOpenEnabled) + const isSearchEnabled = useStore((state) => state.isSearchEnabled) + const isSearchBarVisible = useStore((state) => state.isSearchBarVisible) + const setIsSearchBarVisible = useStore((state) => state.setIsSearchBarVisible) + const {isAuthenticated} = useAuth0() + return ( + + + {isOpenEnabled && + <> + + {isAuthenticated && } + } + {isSearchEnabled && } + {isSearchEnabled && + isSearchBarVisible && + setIsSearchBarVisible(false)} + />} + + + {isNavTreeEnabled && } + {isVersionsEnabled && } + + + ) +} diff --git a/src/Containers/ControlsGroupAndDrawer.jsx b/src/Containers/ControlsGroupAndDrawer.jsx deleted file mode 100644 index f4c54171d..000000000 --- a/src/Containers/ControlsGroupAndDrawer.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, {ReactElement, useEffect} from 'react' -import {useNavigate} from 'react-router-dom' -import Box from '@mui/material/Box' -import Stack from '@mui/material/Stack' -import ControlsGroup from '../Components/ControlsGroup' -import {useWindowDimensions} from '../Components/Hooks' -import NavTreePanel from '../Components/NavTree/NavTreePanel' -import SearchBar from '../Components/Search/SearchBar' -import VersionsPanel from '../Components/Versions/VersionsPanel' -import useStore from '../store/useStore' - - -/** - * @property {Function} deselectItems deselects currently selected element - * @return {ReactElement} - */ -export default function ControlsGroupAndDrawer({ - deselectItems, - pathPrefix, - branch, - selectWithShiftClickEvents, -}) { - // IFCSlice - const model = useStore((state) => state.model) - const rootElement = useStore((state) => state.rootElement) - - // RepositorySlice - const modelPath = useStore((state) => state.modelPath) - - // Slices from Controls - const isNavTreeEnabled = useStore((state) => state.isNavTreeEnabled) - const isNavTreeVisible = useStore((state) => state.isNavTreeVisible) - const setIsNavTreeVisible = useStore((state) => state.setIsNavTreeVisible) - - const isVersionsEnabled = useStore((state) => state.isVersionsEnabled) - const isVersionsVisible = useStore((state) => state.isVersionsVisible) - const setIsVersionsVisible = useStore((state) => state.setIsVersionsVisible) - - // SearchSlice - const isSearchEnabled = useStore((state) => state.isSearchEnabled) - const isSearchBarVisible = useStore((state) => state.isSearchBarVisible) - - const navigate = useNavigate() - - const windowDimensions = useWindowDimensions() - const spacingBetweenSearchAndOpsGroupPx = 20 - const operationsGroupWidthPx = 70 - const searchAndNavWidthPx = - windowDimensions.width - (operationsGroupWidthPx + spacingBetweenSearchAndOpsGroupPx) - const searchAndNavMaxWidthPx = 300 - - - useEffect(() => { - if (isNavTreeVisible && isVersionsVisible) { - setIsVersionsVisible(false) - } - }, [isNavTreeVisible, isVersionsVisible, setIsVersionsVisible]) - - - useEffect(() => { - if (isNavTreeVisible && isVersionsVisible) { - setIsNavTreeVisible(false) - } - }, [isNavTreeVisible, isVersionsVisible, setIsNavTreeVisible]) - - - return ( - - - - - {isSearchEnabled && isSearchBarVisible && } - {isNavTreeEnabled && - isNavTreeVisible && - model && - rootElement && - - } - - {isVersionsEnabled && - modelPath.repo !== undefined && - isVersionsVisible && - !isNavTreeVisible && - } - - - ) -} diff --git a/src/Containers/NavTreeAndVersionsDrawer.jsx b/src/Containers/NavTreeAndVersionsDrawer.jsx new file mode 100644 index 000000000..ad0ff21c0 --- /dev/null +++ b/src/Containers/NavTreeAndVersionsDrawer.jsx @@ -0,0 +1,85 @@ +import React, {ReactElement} from 'react' +import Box from '@mui/material/Box' +import useStore from '../store/useStore' +import NavTreePanel from '../Components/NavTree/NavTreePanel' +import VersionsPanel from '../Components/Versions/VersionsPanel' +import SideDrawer from '../Components/SideDrawer/SideDrawer' + + +/** + * Drawer for NavTree and Versions + * + * @return {ReactElement} + */ +export default function NavTreeAndVersionsDrawer({ + pathPrefix, + branch, + selectWithShiftClickEvents, +}) { + // IFCSlice + const model = useStore((state) => state.model) + const rootElement = useStore((state) => state.rootElement) + + // RepositorySlice + const modelPath = useStore((state) => state.modelPath) + + // Slices from Controls + const isNavTreeEnabled = useStore((state) => state.isNavTreeEnabled) + const isNavTreeVisible = useStore((state) => state.isNavTreeVisible) + const isVersionsEnabled = useStore((state) => state.isVersionsEnabled) + const isVersionsVisible = useStore((state) => state.isVersionsVisible) + const isDrawerVisible = isNavTreeVisible === true || isVersionsVisible === true + + const leftDrawerWidth = useStore((state) => state.leftDrawerWidth) + const leftDrawerWidthInitial = useStore((state) => state.leftDrawerWidthInitial) + const setLeftDrawerWidth = useStore((state) => state.setLeftDrawerWidth) + + return ( + + + {isNavTreeEnabled && + isNavTreeVisible && + model && + rootElement && + } + + + {isVersionsEnabled && + (modelPath.repo !== undefined) && + isVersionsVisible && + } + + + ) +} diff --git a/src/Containers/NavTreeAndVersionsDrawer.test.jsx b/src/Containers/NavTreeAndVersionsDrawer.test.jsx new file mode 100644 index 000000000..b156bd02a --- /dev/null +++ b/src/Containers/NavTreeAndVersionsDrawer.test.jsx @@ -0,0 +1,85 @@ +import React from 'react' +import {__getIfcViewerAPIExtendedMockSingleton} from 'web-ifc-viewer' +import {act, render, renderHook, fireEvent} from '@testing-library/react' +import {useIsMobile} from '../Components/Hooks' +import {TITLE as TITLE_NAV_TREE} from '../Components/NavTree/NavTreePanel' +import {TITLE as TITLE_VERSIONS} from '../Components/Versions/VersionsPanel' +import ShareMock from '../ShareMock' +import useStore from '../store/useStore' +import NavTreeAndVersionsDrawer from './NavTreeAndVersionsDrawer' +import { + MOCK_MODEL_PATH_GIT, + MOCK_REPOSITORY, +} from '../Components/Versions/VersionsPanel.fixture' + + +describe('NavTreeAndVersionsDrawer', () => { + beforeAll(async () => { + const {result} = renderHook(() => useStore((state) => state)) + const viewer = __getIfcViewerAPIExtendedMockSingleton() + viewer.isolator = { + toggleIsolationMode: jest.fn(), + hideSelectedElements: jest.fn(), + unHideAllElements: jest.fn(), + canBeHidden: jest.fn(), + } + await act(() => { + result.current.setViewer(viewer) + }) + }) + + it('properties panel renders', async () => { + const {result: {current: store}} = renderHook(() => useStore((state) => state)) + await act(() => { + store.setModel({getIfcType: jest.fn()}) + store.setModelPath({}) + store.setRootElement({expressID: 0, children: []}) + }) + const {findByText} = render( + + + ) + await act(() => { + store.setIsNavTreeVisible(true) + }) + expect(await findByText(TITLE_NAV_TREE)).toBeVisible() + // reset the store + await act(() => { + store.setSelectedElement({}) + store.toggleIsPropertiesVisible() + }) + }) + + it('double-click resizes horizontally', async () => { + const mobileHook = renderHook(() => useIsMobile()) + const {result: {current: store}} = renderHook(() => useStore((state) => state)) + await act(() => { + // NavTree + store.setModel({getIfcType: jest.fn()}) + store.setModelPath({}) + store.setRootElement({expressID: 0, children: []}) + // Versions + store.setModelPath(MOCK_MODEL_PATH_GIT) + store.setRepository(MOCK_REPOSITORY) + // TODO(pablo): use mock commit data + }) + const notesAndPropsRender = render( + + + ) + await act(() => { + store.setIsVersionsVisible(true) + }) + expect(await notesAndPropsRender.findByText(TITLE_VERSIONS)).toBeVisible() + expect(mobileHook.result.current).toBe(false) + const leftDrawerWidthInitial = store.leftDrawerWidthInitial + const xResizerEl = notesAndPropsRender.getByTestId('x_resizer') + fireEvent.click(xResizerEl) + fireEvent.click(xResizerEl) + const expectedWidth = 350 // TODO(pablo): hack, should be window.innerWidth + expect(store.leftDrawerWidth).toBe(expectedWidth) + fireEvent.click(xResizerEl) + fireEvent.click(xResizerEl) + expect(store.leftDrawerWidth).toBe(leftDrawerWidthInitial) + }) +}) diff --git a/src/Containers/NotesAndPropertiesDrawer.jsx b/src/Containers/NotesAndPropertiesDrawer.jsx new file mode 100644 index 000000000..102597bfb --- /dev/null +++ b/src/Containers/NotesAndPropertiesDrawer.jsx @@ -0,0 +1,59 @@ +import React, {ReactElement} from 'react' +import Box from '@mui/material/Box' +import useStore from '../store/useStore' +import NotesPanel from '../Components/Notes/NotesPanel' +import PropertiesPanel from '../Components/Properties/PropertiesPanel' +import SideDrawer from '../Components/SideDrawer/SideDrawer' + + +/** + * Drawer for Notes and Properties + * + * @return {ReactElement} + */ +export default function NotesAndPropertiesDrawer() { + const isNotesEnabled = useStore((state) => state.isNotesEnabled) + const isNotesVisible = useStore((state) => state.isNotesVisible) + const isPropertiesEnabled = useStore((state) => state.isPropertiesEnabled) + const isPropertiesVisible = useStore((state) => state.isPropertiesVisible) + const rightDrawerWidth = useStore((state) => state.rightDrawerWidth) + const rightDrawerWidthInitial = useStore((state) => state.rightDrawerWidthInitial) + const setRightDrawerWidth = useStore((state) => state.setRightDrawerWidth) + + const isDrawerVisible = isNotesVisible || isPropertiesVisible + + return ( + + + {isNotesEnabled && + isNotesVisible && + } + + + {isPropertiesEnabled && + isPropertiesVisible && + } + + + ) +} diff --git a/src/Containers/NotesAndPropertiesDrawer.test.jsx b/src/Containers/NotesAndPropertiesDrawer.test.jsx new file mode 100644 index 000000000..9674f246f --- /dev/null +++ b/src/Containers/NotesAndPropertiesDrawer.test.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import {act, render, renderHook, fireEvent} from '@testing-library/react' +import {useIsMobile} from '../Components/Hooks' +import {TITLE_NOTES} from '../Components/Notes/component' +import {TITLE as TITLE_PROPS} from '../Components/Properties/component' +import ShareMock from '../ShareMock' +import useStore from '../store/useStore' +import NotesAndPropertiesDrawer from './NotesAndPropertiesDrawer' + + +describe('NotesAndPropertiesDrawer', () => { + it('properties panel renders', async () => { + const {result} = renderHook(() => useStore((state) => state)) + const {findByText} = render() + await act(() => { + result.current.setIsPropertiesVisible(true) + }) + expect(await findByText(TITLE_PROPS)).toBeVisible() + // reset the store + await act(() => { + result.current.setSelectedElement({}) + result.current.toggleIsPropertiesVisible() + }) + }) + + it('double-click resizes horizontally', async () => { + const mobileHook = renderHook(() => useIsMobile()) + const storeHook = renderHook(() => useStore((state) => state)) + const notesAndPropsRender = render() + await act(() => { + storeHook.result.current.toggleIsNotesVisible() + }) + expect(await notesAndPropsRender.findByText(TITLE_NOTES)).toBeVisible() + expect(mobileHook.result.current).toBe(false) + const leftDrawerWidthInitial = storeHook.result.current.leftDrawerWidthInitial + const xResizerEl = notesAndPropsRender.getByTestId('x_resizer') + fireEvent.click(xResizerEl) + fireEvent.click(xResizerEl) + const expectedWidth = 350 // TODO(pablo): hack, should be window.innerWidth + expect(storeHook.result.current.leftDrawerWidth).toBe(expectedWidth) + fireEvent.click(xResizerEl) + fireEvent.click(xResizerEl) + expect(storeHook.result.current.leftDrawerWidth).toBe(leftDrawerWidthInitial) + }) +}) diff --git a/src/Containers/OperationsGroup.jsx b/src/Containers/OperationsGroup.jsx new file mode 100644 index 000000000..d1912d171 --- /dev/null +++ b/src/Containers/OperationsGroup.jsx @@ -0,0 +1,66 @@ +import React, {ReactElement} from 'react' +import Divider from '@mui/material/Divider' +import Stack from '@mui/material/Stack' +import AppsControl from '../Components/Apps/AppsControl' +import CameraControl from '../Components/Camera/CameraControl' +import MarkerControl from '../Components/Markers/MarkerControl' +import ImagineControl from '../Components/Imagine/ImagineControl' +import NotesControl from '../Components/Notes/NotesControl' +import ProfileControl from '../Components/Profile/ProfileControl' +import PropertiesControl from '../Components/Properties/PropertiesControl' +import ShareControl from '../Components/Share/ShareControl' +import useStore from '../store/useStore' + + +/** + * OperationsGroup contains tools for profile, sharing, notes, properties and + * imagine + * + * @return {ReactElement} + */ +export default function OperationsGroup() { + const isAppsEnabled = useStore((state) => state.isAppsEnabled) + const isImagineEnabled = useStore((state) => state.isImagineEnabled) + const isLoginEnabled = useStore((state) => state.isLoginEnabled) + const isNotesEnabled = useStore((state) => state.isNotesEnabled) + const isPropertiesEnabled = useStore((state) => state.isPropertiesEnabled) + const isShareEnabled = useStore((state) => state.isShareEnabled) + const selectedElement = useStore((state) => state.selectedElement) + const isAnElementSelected = selectedElement !== null + + // required for MarkerControl + const viewer = useStore((state) => state.viewer) + const isModelReady = useStore((state) => state.isModelReady) + const model = useStore((state) => state.model) + + return ( + + + {isLoginEnabled && } + {isAppsEnabled && } + {isShareEnabled && } + + + + {isNotesEnabled && } + {isPropertiesEnabled && isAnElementSelected && } + {(viewer && isModelReady) && ( + + )} + {isImagineEnabled && } + {/* Invisible */} + + + + ) +} diff --git a/src/Components/OperationsGroup.test.jsx b/src/Containers/OperationsGroup.test.jsx similarity index 100% rename from src/Components/OperationsGroup.test.jsx rename to src/Containers/OperationsGroup.test.jsx diff --git a/src/Containers/OperationsGroupAndDrawer.jsx b/src/Containers/OperationsGroupAndDrawer.jsx index a680901a0..80408a38a 100644 --- a/src/Containers/OperationsGroupAndDrawer.jsx +++ b/src/Containers/OperationsGroupAndDrawer.jsx @@ -2,8 +2,9 @@ import React, {ReactElement} from 'react' import Box from '@mui/material/Box' import Stack from '@mui/material/Stack' import {useIsMobile} from '../Components/Hooks' -import OperationsGroup from '../Components/OperationsGroup' -import SideDrawer from '../Components/SideDrawer/SideDrawer' +import AppsSideDrawer from './AppsSideDrawer' +import NotesAndProperties from './NotesAndProperties' +import OperationsGroup from './OperationsGroup' /** @@ -34,13 +35,16 @@ export default function OperationsGroupAndDrawer({deselectItems}) { width: '100%', }} > - + ) : ( - + - + + + + ) ) diff --git a/src/Containers/RootLandscape.jsx b/src/Containers/RootLandscape.jsx new file mode 100644 index 000000000..3b2bef422 --- /dev/null +++ b/src/Containers/RootLandscape.jsx @@ -0,0 +1,87 @@ +import React, {ReactElement} from 'react' +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' +import {useIsMobile} from '../Components/Hooks' +import LoadingBackdrop from '../Components/LoadingBackdrop' +import AlertDialogAndSnackbar from './AlertDialogAndSnackbar' +import AppsSideDrawer from './AppsSideDrawer' +import BottomBar from './BottomBar' +import ControlsGroup from './ControlsGroup' +import NavTreeAndVersionsDrawer from './NavTreeAndVersionsDrawer' +import NotesAndPropertiesDrawer from './NotesAndPropertiesDrawer' +import OperationsGroup from './OperationsGroup' +import TabbedPanels from './TabbedPanels' + + +/** + * @property {string} pathPrefix App path prefix + * @property {string} branch For version + * @property {Function} selectWithShiftClickEvents For multi-select by NavTree + * @property {Function} deselectItems deselects currently selected element + * @return {ReactElement} + */ +export default function RootLandscape({pathPrefix, branch, selectWithShiftClickEvents, deselectItems}) { + const isMobile = useIsMobile() + return ( + + {!isMobile && + + + + } + + + + + + + + + + + + {isMobile ? + : + + + + + } + + ) +} diff --git a/src/Containers/TabbedPanels.jsx b/src/Containers/TabbedPanels.jsx new file mode 100644 index 000000000..8ea154a07 --- /dev/null +++ b/src/Containers/TabbedPanels.jsx @@ -0,0 +1,246 @@ +import React, {ReactElement, useState} from 'react' +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' +import Tab from '@mui/material/Tab' +import Tabs from '@mui/material/Tabs' +import AppsPanel, {AppPreviewPanel} from '../Components/Apps/AppsPanel' +import {CloseButton} from '../Components/Buttons' +import NavTreePanel from '../Components/NavTree/NavTreePanel' +import NotesPanel from '../Components/Notes/NotesPanel' +import PropertiesPanel from '../Components/Properties/PropertiesPanel' +import SideDrawer from '../Components/SideDrawer/SideDrawer' +import VersionsPanel from '../Components/Versions/VersionsPanel' +import useStore from '../store/useStore' + + +/** + * @return {ReactElement} + */ +export default function TabbedPanels({ + pathPrefix, + branch, + selectWithShiftClickEvents, +}) { + const selectedApp = useStore((state) => state.selectedApp) + + const isAppsEnabled = useStore((state) => state.isAppsEnabled) + const isAppsVisible = useStore((state) => state.isAppsVisible) + const setIsAppsVisible = useStore((state) => state.setIsAppsVisible) + + const isNavTreeEnabled = useStore((state) => state.isNavTreeEnabled) + const isNavTreeVisible = useStore((state) => state.isNavTreeVisible) + const setIsNavTreeVisible = useStore((state) => state.setIsNavTreeVisible) + + const isNotesEnabled = useStore((state) => state.isNotesEnabled) + const isNotesVisible = useStore((state) => state.isNotesVisible) + const setIsNotesVisible = useStore((state) => state.setIsNotesVisible) + + const isPropertiesEnabled = useStore((state) => state.isPropertiesEnabled) + const isPropertiesVisible = useStore((state) => state.isPropertiesVisible) + const setIsPropertiesVisible = useStore((state) => state.setIsPropertiesVisible) + + const isVersionsEnabled = useStore((state) => state.isVerisonsEnabled) + const isVersionsVisible = useStore((state) => state.isVersionsVisible) + const setIsVersionsVisible = useStore((state) => state.setIsVersionsVisible) + + // Next two are used by NavTree and Versions + // IFCSlice + const model = useStore((state) => state.model) + const rootElement = useStore((state) => state.rootElement) + + // RepositorySlice + const modelPath = useStore((state) => state.modelPath) + + const [value, setValue] = useState(0) + + + const handleChange = (event, newValue) => setValue(newValue) + + + /** + * @param {number} index + * @return {object} + */ + function a11yProps(index) { + return { + 'id': `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + } + } + + + const isDrawerVisible = + isAppsVisible || + isNotesVisible || + isPropertiesVisible || + isVersionsVisible + + + /** @return {boolean} */ + function samePageLinkNavigation(event) { + if ( + event.defaultPrevented || + event.button !== 0 || // ignore everything but left-click + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) { + return false + } + return true + } + + + /** @return {ReactElement} */ + function LinkTab({label, onClose, ...props}) { + return ( + + {label} + + + } + onClick={(event) => { + // Routing libraries handle this, you can remove the onClick handle when using them. + if (samePageLinkNavigation(event)) { + event.preventDefault() + } + }} + aria-current={props.selected && 'page'} + /> + ) + } + + + const labelAndPanels = [] + if (isAppsEnabled && isAppsVisible) { + labelAndPanels.push({ + label: setIsAppsVisible(false)}/>, + panel: !selectedApp ? + : + , + }) + } + if (isNavTreeEnabled && isNavTreeVisible) { + labelAndPanels.push({ + label: setIsNavTreeVisible(false)}/>, + panel: model && + rootElement && + , + }) + } + if (isNotesEnabled && isNotesVisible) { + labelAndPanels.push({ + label: setIsNotesVisible(false)}/>, + panel: , + }) + } + if (isPropertiesEnabled && isPropertiesVisible) { + labelAndPanels.push({ + label: setIsPropertiesVisible(false)}/>, + panel: , + }) + } + if (isVersionsEnabled) { + labelAndPanels.push({ + label: setIsVersionsVisible(false)}/>, + panel: (modelPath.repo !== undefined) && + isVersionsVisible && + , + }) + } + +/* + useEffect(() => { + if (isAppsVisible) { + if (!stack.includes('apps')) { + } + } else if (isNotesVisible) { + setValue(1) + } else if (isPropertiesVisible) { + setValue(2) + } else if (isVersionsVisible) { + setValue(3) + } + }, [isAppsVisible, isNotesVisible, isPropertiesVisible, isVersionsVisible]) +*/ + + return ( + isDrawerVisible && + + console.warn('setDrawerWidth called on mobile drawer')} + dataTestId='TabbedPanels' + > + + + {labelAndPanels.map((entry, index) => ( + + ))} + + {labelAndPanels.map((entry, index) => ( + + {entry.panel} + + ))} + + + + ) +} + + +/** + * @param {object} props + * @return {ReactElement} + */ +function CustomTabPanel(props) { + const {children, value, index, ...other} = props + + return ( + + ) +} diff --git a/src/Share.fixture.jsx b/src/Share.fixture.jsx index 42544599f..13ac7a820 100644 --- a/src/Share.fixture.jsx +++ b/src/Share.fixture.jsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {ReactElement} from 'react' import {HelmetProvider} from 'react-helmet-async' import {MemoryRouter} from 'react-router' import {StoreCtx} from './store/Store.fixture' @@ -6,8 +6,8 @@ import {ThemeCtx} from './theme/Theme.fixture' /** - * @property {Array.} children The component under test - * @return {React.Component} + * @property {Array.} children The component under test + * @return {ReactElement} */ export function RouteThemeCtx({children}) { return {children} @@ -15,8 +15,8 @@ export function RouteThemeCtx({children}) { /** - * @property {Array.} children The component under test - * @return {React.Component} + * @property {Array.} children The component under test + * @return {ReactElement} */ export function StoreRouteThemeCtx({children}) { return {children} @@ -26,8 +26,8 @@ export function StoreRouteThemeCtx({children}) { /** * Mostly for dialogs, which are often titled and routed * - * @property {Array.} children The component under test - * @return {React.Component} + * @property {Array.} children The component under test + * @return {ReactElement} */ export function HelmetStoreRouteThemeCtx({children}) { return {children} diff --git a/src/Share.jsx b/src/Share.jsx index 09dbe42da..8d3a31acc 100644 --- a/src/Share.jsx +++ b/src/Share.jsx @@ -1,4 +1,4 @@ -import React, {ReactElement, useEffect, useMemo, useRef} from 'react' +import React, {ReactElement, useEffect, useMemo} from 'react' import {Helmet} from 'react-helmet-async' import {useNavigate, useParams} from 'react-router-dom' import CssBaseline from '@mui/material/CssBaseline' @@ -25,7 +25,7 @@ import Styles from './Styles' * @return {ReactElement} */ export default function Share({installPrefix, appPrefix, pathPrefix}) { - const navigation = useRef(useNavigate()) + const navigate = useNavigate() const urlParams = useParams() const isAppsEnabled = useStore((state) => state.isAppsEnabled) const modelPath = useStore((state) => state.modelPath) @@ -36,9 +36,9 @@ export default function Share({installPrefix, appPrefix, pathPrefix}) { useMemo(() => { if (isAppsEnabled) { - new WidgetApi(navigation.current, searchIndex) + new WidgetApi(navigate, searchIndex) } - }, [isAppsEnabled, navigation, searchIndex]) + }, [isAppsEnabled, navigate, searchIndex]) /** @@ -54,7 +54,7 @@ export default function Share({installPrefix, appPrefix, pathPrefix}) { const onChangeUrlParams = (() => { const mp = getModelPath(installPrefix, pathPrefix, urlParams) if (mp === null) { - navToDefault(navigation.current, appPrefix) + navToDefault(navigate, appPrefix) return } if (modelPath === null || @@ -77,7 +77,7 @@ export default function Share({installPrefix, appPrefix, pathPrefix}) { } else { debug().warn('No repository set for project!, ', pathPrefix) } - }, [appPrefix, installPrefix, modelPath, pathPrefix, setRepository, urlParams, setModelPath]) + }, [appPrefix, installPrefix, modelPath, pathPrefix, setRepository, urlParams, setModelPath, navigate]) const theme = useShareTheme() diff --git a/src/Styles.jsx b/src/Styles.jsx index 62c6ccc7f..c36063ffa 100644 --- a/src/Styles.jsx +++ b/src/Styles.jsx @@ -58,8 +58,8 @@ export default function Styles({theme}) { marginRight: 'auto', }, '.icon-share': { - width: '20px', - height: '20px', + width: '40px', + height: '40px', fill: theme.palette.primary.contrastText, }, '.icon-small': { diff --git a/src/WidgetApi/ApiConnection.js b/src/WidgetApi/ApiConnection.js index a6aa65a8b..e7a84572a 100644 --- a/src/WidgetApi/ApiConnection.js +++ b/src/WidgetApi/ApiConnection.js @@ -1,7 +1,7 @@ /** * Abstract ApiConnection */ -class AbstractApiConnection { +export default class AbstractApiConnection { /** * event resolver. * @@ -85,5 +85,3 @@ class AbstractApiConnection { } } } - -export default AbstractApiConnection diff --git a/src/WidgetApi/ApiConnectionIframe.js b/src/WidgetApi/ApiConnectionIframe.js index a406107bf..c82aca589 100644 --- a/src/WidgetApi/ApiConnectionIframe.js +++ b/src/WidgetApi/ApiConnectionIframe.js @@ -1,18 +1,15 @@ import {WidgetApi as MatrixWidgetApi} from 'matrix-widget-api/lib/WidgetApi' import {MatrixCapabilities} from 'matrix-widget-api/lib/interfaces/Capabilities' +import debug from '../utils/debug' import AbstractApiConnection from './ApiConnection' -/** - * ApiConnection to Iframed bldrs instance - */ -class ApiConnectionIframe extends AbstractApiConnection { +/** ApiConnection to Iframed bldrs instance */ +export default class ApiConnectionIframe extends AbstractApiConnection { widgetId = 'bldrs-share' matrixWidgetApi = null - /** - * constructor - */ + /** constructor */ constructor() { super() this.matrixWidgetApi = new MatrixWidgetApi(this.widgetId) @@ -20,12 +17,13 @@ class ApiConnectionIframe extends AbstractApiConnection { } /** - * event resolver. + * Handler on Matrix API callbacks * * @param {string} eventName * @param {Function} callable */ on(eventName, callable) { + debug().log('ApiConnectionIframe#on, eventName:', eventName) this.matrixWidgetApi.on( eventName, (event) => { @@ -37,38 +35,35 @@ class ApiConnectionIframe extends AbstractApiConnection { } /** - * send event. + * Send event on Matrix API transport * * @param {string} eventName * @param {object} data */ send(eventName, data) { + debug().log('ApiConnectionIframe#send: eventName:', eventName) this.matrixWidgetApi.transport.send(eventName, data) } /** - * requests capabilities. + * Requests capabilities from other end of Matrix API transport * * @param {string[]} capabilities */ requestCapabilities(capabilities) { + debug().log('ApiConnectionIframe#send: requestCapabilities:', capabilities) this.matrixWidgetApi.requestCapabilities(capabilities) } - /** - * starts the api. - */ + /** Starts the Matrix API message transprot */ start() { + debug().log('ApiConnectionIframe#send: start & sendContentLoaded!') this.matrixWidgetApi.start() this.matrixWidgetApi.sendContentLoaded() } - /** - * stops the api. - */ + /** Stops the Matrix API message transport */ stop() { this.matrixWidgetApi.stop() } } - -export default ApiConnectionIframe diff --git a/src/WidgetApi/WidgetApi.js b/src/WidgetApi/WidgetApi.js index c6ebf03b9..f4d0309d7 100644 --- a/src/WidgetApi/WidgetApi.js +++ b/src/WidgetApi/WidgetApi.js @@ -1,10 +1,9 @@ import ApiConnectionIframe from './ApiConnectionIframe' import ApiEventsRegistry from './ApiEventsRegistry' +import debug from '../utils/debug' -/** - * WidgetApi main class - */ +/** WidgetApi main class */ export default class WidgetApi { /** * constructor @@ -13,17 +12,14 @@ export default class WidgetApi { * @param {object} searchIndex SearchIndex */ constructor(navigation, searchIndex) { + debug().log('WidgetApi#ctor') if (this.detectIframe()) { const apiConnection = new ApiConnectionIframe() new ApiEventsRegistry(apiConnection, navigation, searchIndex) } } - /** - * returns if code is executed in an iframe or not - * - * @return {boolean} - */ + /** @return {boolean} if code is executed in an iframe or not */ detectIframe() { // if this document is hosted in an iframe and has the *same origin* // as the parent document then window.frameElement is to be checked. diff --git a/src/__mocks__/api-handlers.js b/src/__mocks__/api-handlers.js index 71801020e..f5fcfb5fb 100644 --- a/src/__mocks__/api-handlers.js +++ b/src/__mocks__/api-handlers.js @@ -6,12 +6,6 @@ import {MOCK_FILES} from '../net/github/Files.fixture' import {createMockIssues, sampleIssues} from '../net/github/Issues.fixture' import {MOCK_ORGANIZATIONS} from '../net/github/Organizations.fixture' import {MOCK_REPOSITORY, MOCK_USER_REPOSITORIES} from '../net/github/Repositories.fixture' -// import testEnvVars from '../../tools/jest/testEnvVars' - - -// const GH_BASE_AUTHED = 'YO' // process.env.GITHUB_BASE_URL // testEnvVars.GITHUB_BASE_URL -// const GH_BASE_UNAUTHED = testEnvVars.GITHUB_BASE_URL_UNAUTHENTICATED -// console.log('GH_BASE_AUTHED', GH_BASE_AUTHED) const httpOk = 200 diff --git a/src/assets/LogoB.svg b/src/assets/LogoB.svg index c8fe63177..2d0d5ef35 100644 --- a/src/assets/LogoB.svg +++ b/src/assets/LogoB.svg @@ -4,10 +4,10 @@ + BLDRS.AI