From b1497270e9c80f91ad43cf36a24f56fffe515239 Mon Sep 17 00:00:00 2001 From: Jude Allred Date: Fri, 6 Oct 2023 13:26:42 -0400 Subject: [PATCH] H-954: Migrate OSS edition of hCore (#42) * Import sim-core * Hide non-functional UI elements * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Update apps/sim-core/README.md Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> * Tidy .gitignore files * tidy packages/core devdeps * Readme touchups * Hide 'Cloud Status' Header Item * Hide 'account' link from help menu * Package.json updates to support node 20 * Readme: Encourage use of yarn start:core * Populate localstorage with a default 'my project' from WildFires. - This is so that environments with an empty 'localstorage' don't get a blank page. - Also hide the 'my projects' link to the /user page * More robust imports - limits top-level folders to our approved list of src, data, views, dependencies - omits hidden "." files from the import - omits files of unsupported types (as enforced by the preexisting file parsing code) - strips empty top-level folders from the import * Hide unused UI components - The resource browser in the left panel - The 'cloud' options in the experiment dialog * Fix parsing for local datasets; patch by CM * fix export to include hash.json and not attempt to fetch datasets from url; patch by CM * apply yarn fmt * remove import-sort-style-hashintel - It provided customized "import sort" functionality for pretteir, however it was brittle and would break the build now and again. The final straw is that it prevented Windows from running hash, so it can be laid to rest now. * include our proprietary rust package sim-engine-types * Tidy sim-engine-types * sim-engine-types update authors attribution * add vercel.json and dependency installation script * update webpack.config and vercel build command to copy index.html to root of output dir * make install-dependencies script executable * add cargo to path after installing rust * add instructions for self-hosting * Update README * Update README.md * Move sim-engine-types -> engine-types * redirect all paths to site index in vercel * Hide the Activity pane, leaving only the Inspector * yarn fmt * Remove SITE_URL from header link so that relative urls work. * Add a help menu link to GitHub * Update README * Update README * build utils package before engine-web --------- Co-authored-by: David @ HASH <6226576+nonparibus@users.noreply.github.com> Co-authored-by: Ciaran Morinan --- .gitignore | 14 + apps/sim-core/.gitignore | 27 + apps/sim-core/Cargo.lock | 1243 + apps/sim-core/Cargo.toml | 14 + apps/sim-core/README.md | 140 +- .../example_projects/ant-foraging.zip | Bin 0 -> 17094 bytes apps/sim-core/example_projects/boids-3d.zip | Bin 0 -> 8883 bytes .../example_projects/city-infection-model.zip | Bin 0 -> 103249 bytes .../example_projects/connection-example.zip | Bin 0 -> 3888 bytes .../example_projects/empty-project.zip | Bin 0 -> 2641 bytes .../empty-template-project.zip | Bin 0 -> 11593 bytes .../example_projects/model-market.zip | Bin 0 -> 17357 bytes .../published-display-behaviors.zip | Bin 0 -> 18954 bytes apps/sim-core/example_projects/rainfall.zip | Bin 0 -> 18762 bytes .../rumor-mill-public-health-practices.zip | Bin 0 -> 11561 bytes apps/sim-core/example_projects/sugarscape.zip | Bin 0 -> 18446 bytes .../virus-mutation-and-drug-resistance.zip | Bin 0 -> 16140 bytes .../example_projects/warehouse-logistics.zip | Bin 0 -> 27910 bytes .../example_projects/wildfires-regrowth.zip | Bin 0 -> 11151 bytes apps/sim-core/package.json | 149 + apps/sim-core/packages/core/.eslintrc | 38 + apps/sim-core/packages/core/.gitignore | 4 + apps/sim-core/packages/core/.prettierignore | 2 + apps/sim-core/packages/core/README.md | 76 + apps/sim-core/packages/core/babel.config.js | 11 + apps/sim-core/packages/core/codegen.yml | 13 + apps/sim-core/packages/core/package.json | 160 + .../packages/core/scripts/cli/index.ts | 37 + .../core/scripts/cli/utils/generateFile.ts | 62 + .../core/scripts/cli/utils/generateFiles.ts | 73 + .../packages/core/scripts/cli/utils/index.ts | 4 + .../core/scripts/cli/utils/parseArgs.ts | 24 + .../core/scripts/cli/utils/parseIcon.ts | 44 + .../core/scripts/cli/utils/templates.ts | 86 + .../core/scripts/cli/utils/validateIcon.ts | 19 + apps/sim-core/packages/core/scripts/deploy.ts | 192 + .../packages/core/scripts/tsconfig.json | 7 + .../sim-core/packages/core/scripts/types.d.ts | 29 + apps/sim-core/packages/core/site.d.ts | 24 + apps/sim-core/packages/core/src/boot.ts | 36 + .../ActivityHistory/ActivityEmpty.scss | 10 + .../ActivityHistory/ActivityEmpty.tsx | 7 + .../ActivityHistory/ActivityHistory.scss | 127 + .../ActivityHistory/ActivityHistory.tsx | 187 + .../ActivityHistoryGroup.scss | 0 .../ActivityHistoryGroup.tsx | 26 + .../ActivityHistoryGroupSection.scss | 82 + .../ActivityHistoryGroupSection.tsx | 40 + .../ActivityHistoryGroupSectionItem.scss | 33 + .../ActivityHistoryGroupSectionItem.tsx | 71 + .../ActivityHistoryGroupTitle.scss | 22 + .../ActivityHistoryGroupTitle.tsx | 21 + .../ActivityHistory/ActivityHistoryItem.scss | 103 + .../ActivityHistory/ActivityHistoryItem.tsx | 77 + .../ActivityHistoryItemCommit.scss | 3 + .../ActivityHistoryItemCommit.tsx | 55 + .../ActivityHistoryItemCommitGroup.tsx | 61 + .../ActivityHistoryItemTooltip.scss | 7 + .../ActivityHistoryItemTooltip.tsx | 18 + .../ActivityHistoryRelease.scss | 3 + .../ActivityHistoryRelease.tsx | 55 + .../ActivityHistoryRowSpacer.scss | 4 + .../ActivityHistoryRowSpacer.tsx | 7 + .../ActivityHistory/ActivityTime.scss | 9 + .../ActivityHistory/ActivityTime.tsx | 26 + .../AgentHistoryItemIcons.scss | 43 + .../ActivityHistory/AgentHistoryItemIcons.tsx | 44 + .../ExperimentGroup/ExperimentGroup.scss | 24 + .../ExperimentGroup/ExperimentGroup.tsx | 209 + .../ExperimentGroupIconDots.scss | 4 + .../ExperimentGroupIconDots.tsx | 9 + .../ExperimentGroup/ExperimentGroupRun.scss | 79 + .../ExperimentGroup/ExperimentGroupRun.tsx | 153 + .../ExperimentGroupSectionItem.scss | 50 + .../ExperimentGroupSectionItem.tsx | 67 + .../ExperimentGroupSections.tsx | 182 + .../ActivityHistory/ExperimentGroup/hooks.ts | 134 + .../ActivityHistory/ExperimentGroup/utils.ts | 30 + .../ActivityHistory/Inspector/Inspector.css | 137 + .../ActivityHistory/Inspector/Inspector.tsx | 256 + .../SingleRun/ActivityHistorySingleRun.scss | 19 + .../SingleRun/ActivityHistorySingleRun.tsx | 64 + .../src/components/ActivityHistory/hooks.tsx | 499 + .../src/components/ActivityHistory/index.ts | 1 + .../src/components/ActivityHistory/util.ts | 19 + .../src/components/AgentScene/AgentScene.css | 71 + .../src/components/AgentScene/AgentScene.tsx | 218 + .../core/src/components/AgentScene/README.md | 30 + .../AgentScene/components/AgentMesh.tsx | 170 + .../AgentScene/components/AgentRenderer.tsx | 61 + .../AgentScene/components/Controls.tsx | 144 + .../AgentScene/components/HoveredAgent.tsx | 59 + .../AgentScene/components/NetworkEdges.tsx | 142 + .../AgentScene/components/SceneSettings.tsx | 152 + .../AgentScene/components/Stage.tsx | 73 + .../components/AgentScene/state/SceneState.ts | 188 + .../AgentScene/state/resetViewer.ts | 48 + .../AgentScene/state/updateTransitionMap.ts | 171 + .../src/components/AgentScene/state/util.ts | 51 + .../src/components/AgentScene/util/anim.ts | 45 + .../AgentScene/util/builtinmodels.ts | 365 + .../AgentScene/util/geometry-loader.ts | 234 + .../components/Analysis/AnalysisViewer.scss | 246 + .../components/Analysis/AnalysisViewer.tsx | 300 + .../Analysis/AnalysisViewerActionButtons.tsx | 104 + .../Analysis/ButtonCallToAction.scss | 27 + .../Analysis/ButtonCallToAction.spec.tsx | 30 + .../Analysis/ButtonCallToAction.tsx | 14 + .../src/components/Analysis/HelpParagraph.tsx | 36 + .../Analysis/OutputMetricsGrid.scss | 108 + .../Analysis/OutputMetricsGrid.spec.tsx | 45 + .../components/Analysis/OutputMetricsGrid.tsx | 195 + .../components/Analysis/OutputMetricsTab.tsx | 51 + .../core/src/components/Analysis/PlotsTab.tsx | 66 + .../Analysis/TabListActionButtons.scss | 36 + .../src/components/Analysis/modals.test.ts | 571 + .../core/src/components/Analysis/modals.ts | 369 + .../core/src/components/Analysis/types.ts | 188 + .../core/src/components/Analysis/utils.ts | 118 + .../packages/core/src/components/App/App.css | 105 + .../packages/core/src/components/App/App.tsx | 34 + .../packages/core/src/components/App/index.ts | 1 + .../components/BehaviorKeys/BehaviorKeys.css | 14 + .../components/BehaviorKeys/BehaviorKeys.tsx | 61 + .../BehaviorKeys/BehaviorKeysFieldForm.scss | 81 + .../BehaviorKeys/BehaviorKeysFieldForm.tsx | 170 + .../BehaviorKeysFieldFormPopoverOptions.tsx | 84 + .../BehaviorKeys/BehaviorKeysFieldPopover.css | 72 + .../BehaviorKeys/BehaviorKeysFieldPopover.tsx | 50 + .../BehaviorKeys/BehaviorKeysForm.scss | 80 + .../BehaviorKeys/BehaviorKeysForm.tsx | 291 + .../BehaviorKeys/BehaviorKeysProjection.scss | 54 + .../BehaviorKeys/BehaviorKeysProjection.tsx | 161 + .../BehaviorKeys/BehaviorKeysRow.scss | 10 + .../BehaviorKeys/BehaviorKeysRow.tsx | 25 + .../src/components/BehaviorKeys/project.ts | 37 + .../core/src/components/BehaviorKeys/types.ts | 32 + .../core/src/components/BehaviorKeys/utils.ts | 86 + .../src/components/BehaviorKeys/validate.ts | 2 + .../src/components/DataLoader/DataLoader.tsx | 132 + .../src/components/DataLoader/hooks/index.ts | 5 + .../DataLoader/hooks/useDataLoaderParser.ts | 142 + .../core/src/components/DataLoader/types.ts | 51 + .../core/src/components/DataLoader/utils.ts | 117 + .../DataTable/Body/DataTableBody.css | 12 + .../DataTable/Body/DataTableBody.spec.tsx | 10 + .../DataTable/Body/DataTableBody.tsx | 24 + .../src/components/DataTable/Body/index.ts | 1 + .../DataTable/Cell/DataTableCell.css | 42 + .../DataTable/Cell/DataTableCell.spec.tsx | 10 + .../DataTable/Cell/DataTableCell.tsx | 88 + .../src/components/DataTable/Cell/index.ts | 1 + .../src/components/DataTable/DataTable.css | 12 + .../components/DataTable/DataTable.spec.tsx | 10 + .../src/components/DataTable/DataTable.tsx | 42 + .../DataTable/Head/DataTableHead.css | 13 + .../DataTable/Head/DataTableHead.spec.tsx | 10 + .../DataTable/Head/DataTableHead.tsx | 29 + .../src/components/DataTable/Head/index.ts | 1 + .../Pagination/DataTablePagination.css | 33 + .../Pagination/DataTablePagination.spec.tsx | 17 + .../Pagination/DataTablePagination.tsx | 35 + .../components/DataTable/Pagination/index.ts | 1 + .../DataTable/Row/DataTableRow.spec.tsx | 10 + .../components/DataTable/Row/DataTableRow.tsx | 19 + .../src/components/DataTable/Row/index.ts | 1 + .../core/src/components/DataTable/index.ts | 6 + .../DiscordWidget/DiscordWidget.css | 26 + .../DiscordWidget/DiscordWidget.tsx | 76 + .../src/components/DiscordWidget/index.ts | 1 + .../src/components/Dropdown/Dropdown.scss | 222 + .../src/components/Dropdown/Dropdown.spec.tsx | 13 + .../core/src/components/Dropdown/Dropdown.tsx | 144 + .../MenuList/DropdownMenuList.spec.tsx | 10 + .../Dropdown/MenuList/DropdownMenuList.tsx | 61 + .../src/components/Dropdown/MenuList/index.ts | 1 + .../core/src/components/Dropdown/index.ts | 2 + .../core/src/components/Dropdown/types.ts | 31 + .../src/components/EmbedApp/EmbedApp.scss | 0 .../core/src/components/EmbedApp/EmbedApp.tsx | 26 + .../src/components/EmbedApp/bootEmbed.tsx | 62 + .../ErrorBoundary/ErrorBoundary.css | 31 + .../ErrorBoundary/ErrorBoundary.spec.tsx | 10 + .../ErrorBoundary/ErrorBoundary.tsx | 224 + .../src/components/ErrorBoundary/index.ts | 1 + .../components/ErrorDetails/ErrorDetails.css | 4 + .../ErrorDetails/ErrorDetails.spec.tsx | 18 + .../components/ErrorDetails/ErrorDetails.tsx | 34 + .../core/src/components/ErrorDetails/index.ts | 1 + .../components/Fancy/Anchor/FancyAnchor.css | 4 + .../components/Fancy/Anchor/FancyAnchor.tsx | 53 + .../core/src/components/Fancy/Anchor/index.ts | 1 + .../components/Fancy/Button/FancyButton.tsx | 58 + .../Fancy/Button/FancyButtonAsyncTask.scss | 64 + .../Fancy/Button/FancyButtonAsyncTask.tsx | 146 + .../Fancy/Button/FancyButtonWithDropdown.scss | 80 + .../Fancy/Button/FancyButtonWithDropdown.tsx | 80 + .../core/src/components/Fancy/Button/index.ts | 1 + .../core/src/components/Fancy/Fancy.scss | 111 + .../core/src/components/Fancy/index.tsx | 106 + .../FileBanner/Builtin/FileBannerBuiltin.css | 4 + .../Builtin/FileBannerBuiltin.spec.tsx | 10 + .../FileBanner/Builtin/FileBannerBuiltin.tsx | 18 + .../components/FileBanner/Builtin/index.ts | 1 + .../FileBanner/Choose/FileBannerChoose.css | 10 + .../Choose/FileBannerChoose.spec.tsx | 18 + .../FileBanner/Choose/FileBannerChoose.tsx | 29 + .../src/components/FileBanner/Choose/index.ts | 1 + .../src/components/FileBanner/FileBanner.css | 27 + .../PythonSafari/FileBannerPythonSafari.css | 4 + .../FileBannerPythonSafari.spec.tsx | 10 + .../PythonSafari/FileBannerPythonSafari.tsx | 20 + .../FileBanner/PythonSafari/index.ts | 1 + .../FileBanner/Shared/FileBannerShared.css | 18 + .../Shared/FileBannerShared.spec.tsx | 42 + .../FileBanner/Shared/FileBannerShared.tsx | 79 + .../src/components/FileBanner/Shared/index.ts | 1 + .../FileBanner/SignIn/FileBannerSignIn.css | 7 + .../FileBanner/SignIn/FileBannerSignIn.tsx | 17 + .../FileBanner/Upgrade/FileBannerUpgrade.css | 7 + .../Upgrade/FileBannerUpgrade.spec.tsx | 10 + .../FileBanner/Upgrade/FileBannerUpgrade.tsx | 23 + .../components/FileBanner/Upgrade/index.ts | 1 + .../Wrapper/FileBannerWrapper.spec.tsx | 10 + .../FileBanner/Wrapper/FileBannerWrapper.tsx | 142 + .../components/FileBanner/Wrapper/index.ts | 1 + .../core/src/components/FileBanner/index.ts | 6 + .../src/components/FileName/FileName.scss | 10 + .../core/src/components/FileName/FileName.tsx | 12 + .../components/FileName/FileNameWithIcon.scss | 22 + .../components/FileName/FileNameWithIcon.tsx | 32 + .../FileName/FileNameWithShortname.tsx | 15 + .../FileName/FileNameWithShortnameIcon.tsx | 15 + .../FileName/FileNameWithShortnameInner.css | 32 + .../FileName/FileNameWithShortnameInner.tsx | 45 + .../FontsPreloader/FontsPreloader.tsx | 60 + .../src/components/FontsPreloader/index.ts | 1 + .../GeospatialMap/GeospatialMap.css | 15 + .../GeospatialMap/GeospatialMap.tsx | 210 + .../components/GlobalsEditor/GlobalsArray.tsx | 51 + .../GlobalsEditor/GlobalsEditor.scss | 134 + .../GlobalsEditor/GlobalsEditor.tsx | 161 + .../GlobalsEditor/GlobalsObject.tsx | 67 + .../components/GlobalsEditor/GlobalsRow.tsx | 26 + .../GlobalsEditor/GlobalsRowContainer.tsx | 46 + .../GlobalsEditor/GlobalsRowField.tsx | 101 + .../src/components/GlobalsEditor/index.ts | 1 + .../src/components/GlobalsEditor/types.ts | 6 + .../src/components/GlobalsEditor/utils.ts | 43 + .../AccessGate/HashCoreAccessGate.css | 66 + .../AccessGate/HashCoreAccessGate.tsx | 38 + .../HashCoreAccessGateNotFound.spec.tsx | 17 + .../NotFound/HashCoreAccessGateNotFound.tsx | 25 + .../HashCore/AccessGate/NotFound/index.ts | 3 + .../components/HashCore/AccessGate/enums.ts | 3 + .../components/HashCore/AccessGate/index.ts | 2 + .../components/HashCore/AccessGate/types.ts | 7 + .../components/HashCore/AccessGate/util.ts | 28 + .../HashCore/Aside/HashCoreAside.css | 15 + .../HashCore/Aside/HashCoreAside.tsx | 28 + .../src/components/HashCore/Aside/index.ts | 1 + .../HashCore/Console/HashCoreConsole.css | 106 + .../HashCore/Console/HashCoreConsole.tsx | 90 + .../HashCore/Console/HashCoreConsoleAlert.tsx | 91 + .../src/components/HashCore/Console/index.ts | 1 + .../ContextMenu/HashCoreContextMenu.css | 42 + .../ContextMenu/HashCoreContextMenu.spec.tsx | 10 + .../ContextMenu/HashCoreContextMenu.tsx | 16 + .../components/HashCore/ContextMenu/index.ts | 1 + .../HashCore/Editor/HashCoreEditor.scss | 87 + .../HashCore/Editor/HashCoreEditor.tsx | 451 + .../HashCoreEditorBehaviorKeysFileAction.tsx | 43 + .../HashCore/Editor/HashCoreEditorFile.tsx | 121 + .../src/components/HashCore/Editor/index.ts | 1 + .../src/components/HashCore/Editor/utils.ts | 77 + .../HashCoreEditorContainer.css | 7 + .../HashCoreEditorContainer.tsx | 13 + .../HashCore/EditorContainer/index.ts | 1 + .../HashCore/Files/HashCoreFiles.scss | 42 + .../HashCore/Files/HashCoreFiles.tsx | 234 + .../Files/HashCoreFilesHeaderAction.scss | 88 + .../Files/HashCoreFilesHeaderAction.tsx | 80 + .../Files/ListItem/HashCoreFilesListItem.scss | 27 + .../Files/ListItem/HashCoreFilesListItem.tsx | 16 + .../HashCoreFilesListItemFile.scss | 84 + .../HashCoreFilesListItemFile.spec.tsx | 41 + .../HashCoreFilesListItemFile.tsx | 320 + .../HashCoreFilesListItemFilePending.tsx | 13 + .../HashCore/Files/ListItemFile/index.ts | 6 + .../HashCoreFilesListItemFolder.css | 30 + .../HashCoreFilesListItemFolder.spec.tsx | 167 + .../HashCoreFilesListItemFolder.tsx | 94 + .../HashCore/Files/ListItemFolder/index.ts | 1 + .../Files/Search/HashCoreFilesSearch.css | 19 + .../Files/Search/HashCoreFilesSearch.tsx | 83 + .../Search/HashCoreFilesSearchContainer.tsx | 71 + .../Files/Search/HashCoreFilesSearchFade.css | 15 + .../Files/Search/HashCoreFilesSearchFade.tsx | 7 + .../Files/Search/HashCoreFilesSearchFile.scss | 30 + .../Files/Search/HashCoreFilesSearchFile.tsx | 77 + .../Files/Search/HashCoreFilesSearchForm.css | 30 + .../Files/Search/HashCoreFilesSearchForm.tsx | 157 + .../Files/Search/HashCoreFilesSearchInput.css | 48 + .../Files/Search/HashCoreFilesSearchInput.tsx | 31 + .../HashCoreFilesSearchItemWithIcons.scss | 24 + .../HashCoreFilesSearchItemWithIcons.tsx | 34 + .../Search/HashCoreFilesSearchMatch.scss | 41 + .../Files/Search/HashCoreFilesSearchMatch.tsx | 75 + .../Search/HashCoreFilesSearchProgress.css | 58 + .../Search/HashCoreFilesSearchProgress.tsx | 45 + .../Files/Search/MonacoIconComponents.scss | 30 + .../Files/Search/MonacoIconComponents.tsx | 91 + .../components/HashCore/Files/Search/hooks.ts | 394 + .../components/HashCore/Files/Search/index.ts | 2 + .../HashCore/Files/Search/monaco/README.md | 4 + .../HashCore/Files/Search/monaco/charCode.ts | 37 + .../HashCore/Files/Search/monaco/index.ts | 1 + .../Files/Search/monaco/replacePattern.ts | 373 + .../HashCore/Files/Search/monaco/search.ts | 85 + .../HashCore/Files/Search/monaco/strings.ts | 11 + .../HashCore/Files/Search/reducer.ts | 208 + .../components/HashCore/Files/Search/types.ts | 32 + .../components/HashCore/Files/Search/util.ts | 174 + .../components/HashCore/Files/hooks/index.ts | 3 + .../Files/hooks/useModalNameBehavior.tsx | 84 + .../HashCore/Files/hooks/useName.ts | 231 + .../Files/hooks/useNameNewBehaviorModal.ts | 24 + .../Files/hooks/useRenameBehaviorModal.ts | 26 + .../src/components/HashCore/Files/index.ts | 8 + .../core/src/components/HashCore/HashCore.tsx | 155 + .../HashCore/Header/HashCoreHeader.css | 156 + .../HashCore/Header/HashCoreHeader.tsx | 216 + .../HashVersionPicker/HashVersionPicker.css | 6 + .../HashVersionPicker/HashVersionPicker.tsx | 173 + .../Header/HashVersionPicker/index.ts | 1 + .../HashCoreHeaderMenuCloudStatus.css | 54 + .../HashCoreHeaderMenuCloudStatus.tsx | 62 + .../HashCoreHeaderMenuExperiments.tsx | 121 + .../HashCore/Header/Menu/Experiments/index.ts | 1 + .../Files/HashCoreHeaderMenuFiles.spec.tsx | 31 + .../Menu/Files/HashCoreHeaderMenuFiles.tsx | 348 + .../HashCore/Header/Menu/Files/index.ts | 1 + .../Header/Menu/HashCoreHeaderMenu.scss | 206 + .../Header/Menu/HashCoreHeaderMenu.tsx | 94 + .../Menu/Help/HashCoreHeaderMenuHelp.tsx | 100 + .../HashCore/Header/Menu/Help/index.ts | 1 + .../Menu/View/HashCoreHeaderMenuView.tsx | 197 + .../HashCore/Header/Menu/View/index.ts | 1 + .../HashCore/Header/Menu/hooks/index.ts | 2 + .../Header/Menu/hooks/useClickOutside.ts | 20 + .../HashCore/Header/Menu/hooks/useMenu.ts | 132 + .../components/HashCore/Header/Menu/index.ts | 2 + .../ShareButton/HashCoreHeaderShareButton.tsx | 29 + .../UserImage/HashCoreHeaderUserImage.css | 16 + .../UserImage/HashCoreHeaderUserImage.tsx | 29 + .../src/components/HashCore/Header/index.ts | 2 + .../components/HashCore/Main/HashCoreMain.css | 5 + .../components/HashCore/Main/HashCoreMain.tsx | 47 + .../src/components/HashCore/Main/index.ts | 1 + .../core/src/components/HashCore/Main/util.ts | 47 + .../HashCore/Resources/HashCoreResources.css | 29 + .../HashCore/Resources/HashCoreResources.tsx | 12 + .../Resources/List/HashCoreResourcesList.css | 6 + .../Resources/List/HashCoreResourcesList.tsx | 22 + .../HashCore/Resources/List/index.ts | 1 + .../HashCoreResourcesSearchableIndex.tsx | 16 + .../Resources/SearchableIndex/hooks.ts | 150 + .../Resources/SearchableIndex/index.ts | 1 + .../components/HashCore/Resources/index.ts | 4 + .../HashCore/Resources/selectors.ts | 42 + .../HashCore/Section/HashCoreSection.css | 65 + .../HashCore/Section/HashCoreSection.tsx | 61 + .../src/components/HashCore/Section/index.ts | 1 + .../components/HashCore/Tour/HashCoreTour.css | 241 + .../components/HashCore/Tour/HashCoreTour.tsx | 359 + .../Tour/Step/HashCoreTourStepAgents.tsx | 52 + .../Tour/Step/HashCoreTourStepDatasets.tsx | 54 + .../Tour/Step/HashCoreTourStepDone.css | 92 + .../Tour/Step/HashCoreTourStepDone.tsx | 105 + .../Tour/Step/HashCoreTourStepIntro.tsx | 31 + .../Tour/Step/HashCoreTourStepPause.tsx | 36 + .../Tour/Step/HashCoreTourStepPlay.tsx | 43 + .../Tour/Step/HashCoreTourStepPlots.tsx | 75 + .../Tour/Step/HashCoreTourStepRefresh.tsx | 118 + .../components/HashCore/Tour/Step/index.ts | 2 + .../components/HashCore/Tour/Step/steps.tsx | 142 + .../components/HashCore/Tour/Step/util.tsx | 352 + .../src/components/HashCore/Tour/index.ts | 2 + .../HashCore/Tour/react-shepherd-wrapper.tsx | 77 + .../core/src/components/HashCore/Tour/util.ts | 11 + .../HashCore/Viewer/HashCoreViewer.css | 52 + .../HashCore/Viewer/HashCoreViewer.tsx | 67 + .../src/components/HashCore/Viewer/index.ts | 1 + .../core/src/components/HashCore/index.ts | 16 + .../HashCore/useInstructionReceiver.ts | 265 + .../components/HashCore/utils/getUserOrgs.ts | 20 + .../src/components/HashCore/utils/index.ts | 1 + .../HashRouter/Effect/DefaultProject.tsx | 32 + .../src/components/HashRouter/Effect/Fork.tsx | 125 + .../HashRouter/Effect/LegacySimulation.tsx | 50 + .../HashRouter/Effect/NewProject.tsx | 96 + .../components/HashRouter/Effect/NotFound.tsx | 23 + .../components/HashRouter/Effect/Onboard.tsx | 20 + .../components/HashRouter/Effect/Project.tsx | 127 + .../components/HashRouter/Effect/Signin.tsx | 32 + .../components/HashRouter/Effect/Signup.tsx | 32 + .../src/components/HashRouter/Effect/hooks.ts | 67 + .../src/components/HashRouter/Effect/index.ts | 1 + .../components/HashRouter/Effect/routes.tsx | 56 + .../HashRouter/Effect/templates/empty.ts | 74 + .../HashRouter/Effect/templates/starter.ts | 114 + .../HashRouter/Effect/templates/templates.ts | 8 + .../HashRouter/Effect/templates/types.ts | 3 + .../src/components/HashRouter/HashRouter.tsx | 37 + .../core/src/components/HashRouter/index.ts | 1 + .../IconAccountMultiple.spec.tsx | 10 + .../AccountMultiple/IconAccountMultiple.tsx | 20 + .../components/Icon/AccountMultiple/index.ts | 1 + .../AddDatapoint/IconAddDatapoint.spec.tsx | 10 + .../Icon/AddDatapoint/IconAddDatapoint.tsx | 20 + .../src/components/Icon/AddDatapoint/index.ts | 1 + .../components/Icon/Alert/IconAlert.spec.tsx | 10 + .../src/components/Icon/Alert/IconAlert.tsx | 20 + .../core/src/components/Icon/Alert/index.ts | 1 + .../AlertOutline/IconAlertOutline.spec.tsx | 10 + .../Icon/AlertOutline/IconAlertOutline.tsx | 20 + .../src/components/Icon/AlertOutline/index.ts | 1 + .../ArrowDownDrop/IconArrowDownDrop.spec.tsx | 10 + .../Icon/ArrowDownDrop/IconArrowDownDrop.tsx | 16 + .../components/Icon/ArrowDownDrop/index.ts | 1 + .../ArrowLeftBold/IconArrowLeftBold.spec.tsx | 10 + .../Icon/ArrowLeftBold/IconArrowLeftBold.tsx | 16 + .../components/Icon/ArrowLeftBold/index.ts | 1 + .../IconArrowRightBold.spec.tsx | 10 + .../ArrowRightBold/IconArrowRightBold.tsx | 16 + .../components/Icon/ArrowRightBold/index.ts | 1 + .../Icon/AutoFix/IconAutoFix.spec.tsx | 10 + .../components/Icon/AutoFix/IconAutoFix.tsx | 21 + .../core/src/components/Icon/AutoFix/index.ts | 1 + .../Icon/Beaker/IconBeaker.spec.tsx | 10 + .../src/components/Icon/Beaker/IconBeaker.tsx | 20 + .../core/src/components/Icon/Beaker/index.ts | 1 + .../components/Icon/Brain/IconBrain.spec.tsx | 10 + .../src/components/Icon/Brain/IconBrain.tsx | 16 + .../core/src/components/Icon/Brain/index.ts | 1 + .../Icon/Cancel/IconCancel.spec.tsx | 10 + .../src/components/Icon/Cancel/IconCancel.tsx | 20 + .../core/src/components/Icon/Cancel/index.ts | 1 + .../IconChartBarStacked.spec.tsx | 10 + .../ChartBarStacked/IconChartBarStacked.tsx | 20 + .../components/Icon/ChartBarStacked/index.ts | 1 + .../Icon/ChartLine/IconChartLine.spec.tsx | 10 + .../Icon/ChartLine/IconChartLine.tsx | 16 + .../src/components/Icon/ChartLine/index.ts | 1 + .../IconChartLineVariant.spec.tsx | 10 + .../ChartLineVariant/IconChartLineVariant.tsx | 16 + .../components/Icon/ChartLineVariant/index.ts | 1 + .../components/Icon/Check/IconCheck.spec.tsx | 10 + .../src/components/Icon/Check/IconCheck.tsx | 16 + .../core/src/components/Icon/Check/index.ts | 1 + .../IconCheckboxMarkedCircleOutline.spec.tsx | 10 + .../IconCheckboxMarkedCircleOutline.tsx | 22 + .../Icon/CheckboxMarkedCircleOutline/index.ts | 1 + .../IconCheckboxMarkedOutline.spec.tsx | 10 + .../IconCheckboxMarkedOutline.tsx | 20 + .../Icon/CheckboxMarkedOutline/index.ts | 1 + .../ChevronRight/IconChevronRight.spec.tsx | 10 + .../Icon/ChevronRight/IconChevronRight.tsx | 17 + .../src/components/Icon/ChevronRight/index.ts | 1 + .../src/components/Icon/Close/IconClose.tsx | 17 + .../core/src/components/Icon/Close/index.ts | 1 + .../components/Icon/Cloud/IconCloud.spec.tsx | 10 + .../src/components/Icon/Cloud/IconCloud.tsx | 17 + .../core/src/components/Icon/Cloud/index.ts | 1 + .../CodeTagsCheck/IconCodeTagsCheck.spec.tsx | 10 + .../Icon/CodeTagsCheck/IconCodeTagsCheck.tsx | 21 + .../components/Icon/CodeTagsCheck/index.ts | 1 + .../Icon/ContentCopy/IconContentCopy.spec.tsx | 10 + .../Icon/ContentCopy/IconContentCopy.tsx | 21 + .../src/components/Icon/ContentCopy/index.ts | 1 + .../IconContentDuplicate.spec.tsx | 10 + .../ContentDuplicate/IconContentDuplicate.tsx | 21 + .../components/Icon/ContentDuplicate/index.ts | 1 + .../src/components/Icon/Copy/IconCopy.tsx | 21 + .../core/src/components/Icon/Copy/index.ts | 1 + .../IconCreateDashboard.spec.tsx | 10 + .../CreateDashboard/IconCreateDashboard.tsx | 22 + .../components/Icon/CreateDashboard/index.ts | 1 + .../Icon/CreatePlot/IconCreatePlot.spec.tsx | 10 + .../Icon/CreatePlot/IconCreatePlot.tsx | 17 + .../src/components/Icon/CreatePlot/index.ts | 1 + .../CubeUnfolded/IconCubeUnfolded.spec.tsx | 10 + .../Icon/CubeUnfolded/IconCubeUnfolded.tsx | 20 + .../src/components/Icon/CubeUnfolded/index.ts | 1 + .../components/Icon/Desktop/IconDesktop.tsx | 21 + .../core/src/components/Icon/Desktop/index.ts | 1 + .../IconDirectionsFork.spec.tsx | 10 + .../DirectionsFork/IconDirectionsFork.tsx | 21 + .../components/Icon/DirectionsFork/index.ts | 1 + .../Icon/Discord/IconDiscord.spec.tsx | 10 + .../components/Icon/Discord/IconDiscord.tsx | 13 + .../core/src/components/Icon/Discord/index.ts | 1 + .../IconDotsHorizontal.spec.tsx | 10 + .../DotsHorizontal/IconDotsHorizontal.tsx | 25 + .../components/Icon/DotsHorizontal/index.ts | 1 + .../DotsVertical/IconDotsVertical.spec.tsx | 10 + .../Icon/DotsVertical/IconDotsVertical.tsx | 21 + .../src/components/Icon/DotsVertical/index.ts | 1 + .../Icon/Download/IconDownload.spec.tsx | 10 + .../components/Icon/Download/IconDownload.tsx | 21 + .../src/components/Icon/Download/index.ts | 1 + .../DragVertical/IconDragVertical.spec.tsx | 10 + .../Icon/DragVertical/IconDragVertical.tsx | 22 + .../src/components/Icon/DragVertical/index.ts | 1 + .../components/Icon/Earth/IconEarth.spec.tsx | 10 + .../src/components/Icon/Earth/IconEarth.tsx | 20 + .../core/src/components/Icon/Earth/index.ts | 1 + .../IconExperimentsCreate.spec.tsx | 10 + .../IconExperimentsCreate.tsx | 20 + .../Icon/ExperimentsCreate/index.ts | 1 + .../IconExperimentsRun.spec.tsx | 10 + .../ExperimentsRun/IconExperimentsRun.tsx | 20 + .../components/Icon/ExperimentsRun/index.ts | 1 + .../src/components/Icon/Eye/IconEye.spec.tsx | 10 + .../core/src/components/Icon/Eye/IconEye.tsx | 15 + .../core/src/components/Icon/Eye/index.ts | 1 + .../Icon/EyeOutline/IconEyeOutline.spec.tsx | 10 + .../Icon/EyeOutline/IconEyeOutline.tsx | 22 + .../src/components/Icon/EyeOutline/index.ts | 1 + .../Icon/FileFind/IconFileFind.spec.tsx | 10 + .../components/Icon/FileFind/IconFileFind.tsx | 20 + .../src/components/Icon/FileFind/index.ts | 1 + .../Icon/FileOutline/IconFileOutline.spec.tsx | 10 + .../Icon/FileOutline/IconFileOutline.tsx | 21 + .../src/components/Icon/FileOutline/index.ts | 1 + .../Icon/FilePlus/IconFilePlus.spec.tsx | 10 + .../components/Icon/FilePlus/IconFilePlus.tsx | 20 + .../src/components/Icon/FilePlus/index.ts | 1 + .../Icon/Filter/IconFilter.spec.tsx | 10 + .../src/components/Icon/Filter/IconFilter.tsx | 16 + .../core/src/components/Icon/Filter/index.ts | 1 + .../src/components/Icon/Folder/IconFolder.tsx | 21 + .../core/src/components/Icon/Folder/index.ts | 1 + .../Icon/FolderLock/IconFolderLock.spec.tsx | 10 + .../Icon/FolderLock/IconFolderLock.tsx | 20 + .../src/components/Icon/FolderLock/index.ts | 1 + .../Icon/FolderOpen/IconFolderOpen.tsx | 17 + .../src/components/Icon/FolderOpen/index.ts | 1 + .../Icon/HCoreMono/IconHCoreMono.spec.tsx | 10 + .../Icon/HCoreMono/IconHCoreMono.tsx | 93 + .../src/components/Icon/HCoreMono/index.ts | 1 + .../Icon/HIndex/IconHIndex.spec.tsx | 10 + .../src/components/Icon/HIndex/IconHIndex.tsx | 49 + .../core/src/components/Icon/HIndex/index.ts | 1 + .../Icon/HelpCircle/IconHelpCircle.spec.tsx | 10 + .../Icon/HelpCircle/IconHelpCircle.tsx | 20 + .../src/components/Icon/HelpCircle/index.ts | 1 + .../IconHelpCircleOutline.spec.tsx | 10 + .../IconHelpCircleOutline.tsx | 21 + .../Icon/HelpCircleOutline/index.ts | 1 + .../core/src/components/Icon/Icon.css | 4 + .../Icon/Import/IconImport.spec.tsx | 10 + .../src/components/Icon/Import/IconImport.tsx | 20 + .../core/src/components/Icon/Import/index.ts | 1 + .../IconInformationOutline.spec.tsx | 10 + .../IconInformationOutline.tsx | 20 + .../Icon/InformationOutline/index.ts | 1 + .../Icon/KeyPlus/IconKeyPlus.spec.tsx | 10 + .../components/Icon/KeyPlus/IconKeyPlus.tsx | 21 + .../core/src/components/Icon/KeyPlus/index.ts | 1 + .../IconKeyboardReturn.spec.tsx | 10 + .../KeyboardReturn/IconKeyboardReturn.tsx | 16 + .../components/Icon/KeyboardReturn/index.ts | 1 + .../components/Icon/Loading/IconLoading.scss | 13 + .../components/Icon/Loading/IconLoading.tsx | 102 + .../Icon/Loading/LazyIconLoading.tsx | 17 + .../core/src/components/Icon/Loading/index.ts | 1 + .../core/src/components/Icon/Loading/types.ts | 6 + .../components/Icon/Lock/IconLock.spec.tsx | 10 + .../src/components/Icon/Lock/IconLock.tsx | 15 + .../core/src/components/Icon/Lock/index.ts | 1 + .../components/Icon/Magnify/IconMagnify.tsx | 21 + .../core/src/components/Icon/Magnify/index.ts | 1 + .../Icon/MenuDown/IconMenuDown.spec.tsx | 10 + .../components/Icon/MenuDown/IconMenuDown.tsx | 16 + .../src/components/Icon/MenuDown/index.ts | 1 + .../components/Icon/More/IconMore.spec.tsx | 10 + .../src/components/Icon/More/IconMore.tsx | 14 + .../core/src/components/Icon/More/index.ts | 1 + .../Icon/OpenInNew/IconOpenInNew.spec.tsx | 10 + .../Icon/OpenInNew/IconOpenInNew.tsx | 20 + .../src/components/Icon/OpenInNew/index.ts | 1 + .../Icon/PackageDown/IconPackageDown.spec.tsx | 10 + .../Icon/PackageDown/IconPackageDown.tsx | 20 + .../src/components/Icon/PackageDown/index.ts | 1 + .../components/Icon/Pause/IconPause.spec.tsx | 10 + .../src/components/Icon/Pause/IconPause.tsx | 20 + .../core/src/components/Icon/Pause/index.ts | 1 + .../src/components/Icon/Pencil/IconPencil.tsx | 21 + .../core/src/components/Icon/Pencil/index.ts | 1 + .../components/Icon/Plus/IconPlus.spec.tsx | 10 + .../src/components/Icon/Plus/IconPlus.tsx | 17 + .../core/src/components/Icon/Plus/index.ts | 1 + .../IconPresentationPause.spec.tsx | 10 + .../IconPresentationPause.tsx | 20 + .../Icon/PresentationPause/index.ts | 1 + .../IconPresentationPlay.spec.tsx | 10 + .../PresentationPlay/IconPresentationPlay.tsx | 20 + .../components/Icon/PresentationPlay/index.ts | 1 + .../Icon/Python/IconPython.spec.tsx | 10 + .../src/components/Icon/Python/IconPython.tsx | 17 + .../core/src/components/Icon/Python/index.ts | 1 + .../Icon/Restart/IconRestart.spec.tsx | 10 + .../components/Icon/Restart/IconRestart.tsx | 20 + .../core/src/components/Icon/Restart/index.ts | 1 + .../Icon/RunFast/IconRunFast.spec.tsx | 10 + .../components/Icon/RunFast/IconRunFast.tsx | 20 + .../core/src/components/Icon/RunFast/index.ts | 1 + .../components/Icon/Rust/IconRust.spec.tsx | 10 + .../src/components/Icon/Rust/IconRust.tsx | 11 + .../core/src/components/Icon/Rust/index.ts | 1 + .../src/components/Icon/Server/IconServer.tsx | 21 + .../core/src/components/Icon/Server/index.ts | 1 + .../Icon/Settings/IconSettings.spec.tsx | 10 + .../components/Icon/Settings/IconSettings.tsx | 23 + .../src/components/Icon/Settings/index.ts | 1 + .../components/Icon/Sparkles/IconSparkles.tsx | 21 + .../src/components/Icon/Sparkles/index.ts | 1 + .../components/Icon/Spinner/IconSpinner.css | 14 + .../Icon/Spinner/IconSpinner.spec.tsx | 10 + .../components/Icon/Spinner/IconSpinner.tsx | 32 + .../core/src/components/Icon/Spinner/index.ts | 1 + .../components/Icon/Stop/IconStop.spec.tsx | 10 + .../src/components/Icon/Stop/IconStop.tsx | 11 + .../core/src/components/Icon/Stop/index.ts | 1 + .../components/Icon/Sync/IconSync.spec.tsx | 10 + .../src/components/Icon/Sync/IconSync.tsx | 15 + .../core/src/components/Icon/Sync/index.ts | 1 + .../Icon/TableAdd/IconTableAdd.spec.tsx | 10 + .../components/Icon/TableAdd/IconTableAdd.tsx | 23 + .../src/components/Icon/TableAdd/index.ts | 1 + .../Icon/TableLarge/IconTableLarge.spec.tsx | 10 + .../Icon/TableLarge/IconTableLarge.tsx | 20 + .../src/components/Icon/TableLarge/index.ts | 1 + .../components/Icon/Trash/IconTrash.spec.tsx | 10 + .../src/components/Icon/Trash/IconTrash.tsx | 21 + .../core/src/components/Icon/Trash/index.ts | 1 + .../components/Icon/Tree/IconTree.spec.tsx | 10 + .../src/components/Icon/Tree/IconTree.tsx | 15 + .../core/src/components/Icon/Tree/index.ts | 1 + .../Icon/Update/IconUpdate.spec.tsx | 10 + .../src/components/Icon/Update/IconUpdate.tsx | 20 + .../core/src/components/Icon/Update/index.ts | 1 + .../Icon/Upload/IconUpload.spec.tsx | 10 + .../src/components/Icon/Upload/IconUpload.tsx | 21 + .../core/src/components/Icon/Upload/index.ts | 1 + .../core/src/components/Icon/index.ts | 58 + .../Inputs/Checkbox/CheckboxInput.css | 38 + .../Inputs/Checkbox/CheckboxInput.tsx | 22 + .../Inputs/EnumInput/EnumInput.scss | 16 + .../components/Inputs/EnumInput/EnumInput.tsx | 39 + .../src/components/Inputs/EnumInput/index.ts | 1 + .../components/Inputs/Radio/RadioInput.scss | 65 + .../components/Inputs/Radio/RadioInput.tsx | 28 + .../RoundedTextInput/RoundedTextInput.css | 26 + .../RoundedTextInput/RoundedTextInput.tsx | 28 + .../Inputs/RoundedTextInput/index.ts | 2 + .../Inputs/Select/RoundedSelect.css | 21 + .../Inputs/Select/RoundedSelect.tsx | 24 + .../src/components/Inputs/Select/Select.css | 37 + .../src/components/Inputs/Select/Select.tsx | 87 + .../src/components/Inputs/Select/types.ts | 18 + .../TextOrNumberInput/TextOrNumberInput.scss | 126 + .../TextOrNumberInput/TextOrNumberInput.tsx | 42 + .../Inputs/TextOrNumberInput/index.ts | 1 + .../core/src/components/Inputs/index.ts | 4 + .../src/components/KeepInView/KeepInView.tsx | 101 + .../core/src/components/KeepInView/index.ts | 1 + .../LabeledInputRadio/LabeledInputRadio.css | 29 + .../LabeledInputRadio/LabeledInputRadio.tsx | 47 + .../src/components/LabeledInputRadio/index.ts | 1 + .../core/src/components/Link/Link.tsx | 93 + .../core/src/components/Link/LinkBehavior.tsx | 18 + .../components/LoadingIcon/LoadingIcon.css | 82 + .../components/LoadingIcon/LoadingIcon.tsx | 18 + .../core/src/components/LoadingIcon/index.ts | 1 + .../core/src/components/Logo/Logo.css | 52 + .../core/src/components/Logo/Logo.spec.tsx | 10 + .../core/src/components/Logo/Logo.tsx | 50 + .../core/src/components/Logo/index.ts | 1 + .../Modal/Analysis/AnalysisModal.scss | 191 + .../Modal/Analysis/AnalysisModal.tsx | 57 + .../Modal/Analysis/ModalOutputMetrics.scss | 65 + .../Analysis/ModalOutputMetrics.spec.tsx | 88 + .../Modal/Analysis/ModalOutputMetrics.tsx | 269 + .../components/Modal/Analysis/ModalPlots.scss | 59 + .../Modal/Analysis/ModalPlots.spec.tsx | 29 + .../components/Modal/Analysis/ModalPlots.tsx | 511 + .../Modal/Analysis/OperationItem.tsx | 166 + .../components/Modal/Analysis/YAxisItem.tsx | 51 + .../core/src/components/Modal/BigModal.css | 16 + .../core/src/components/Modal/BigModal.tsx | 32 + .../Modal/CloudUsage/ModalCloudUsage.css | 85 + .../Modal/CloudUsage/ModalCloudUsage.tsx | 51 + .../src/components/Modal/CloudUsage/index.ts | 2 + .../Modal/CloudUsage/useModalCloudUsage.tsx | 12 + .../FileDelete/ModalConfirmFileDelete.css | 31 + .../FileDelete/ModalConfirmFileDelete.tsx | 50 + .../src/components/Modal/Confirm/hooks.ts | 18 + .../Modal/Experiments/ExperimentModal.scss | 196 + .../Experiments/ExperimentModal.spec.tsx | 506 + .../Modal/Experiments/ExperimentModal.tsx | 1494 ++ .../Experiments/ExperimentTypeTooltip.tsx | 74 + .../src/components/Modal/Experiments/types.ts | 234 + .../components/Modal/Experiments/utils.tsx | 212 + .../Modal/Experiments/valuesParser.spec.ts | 79 + .../Modal/Experiments/valuesParser.ts | 184 + .../Dropdown/ModalFormEntryDropdown.spec.tsx | 18 + .../Dropdown/ModalFormEntryDropdown.tsx | 42 + .../Modal/FormEntry/Dropdown/hooks/index.ts | 4 + .../FormEntry/Dropdown/hooks/useKeywords.ts | 30 + .../FormEntry/Dropdown/hooks/useLicenses.ts | 38 + .../FormEntry/Dropdown/hooks/usePublishAs.ts | 42 + .../FormEntry/Dropdown/hooks/useSubjects.ts | 28 + .../Modal/FormEntry/Dropdown/index.ts | 2 + .../Modal/FormEntry/ModalFormEntry.css | 98 + .../Modal/FormEntry/ModalFormEntry.spec.tsx | 10 + .../Modal/FormEntry/ModalFormEntry.tsx | 68 + .../Modal/FormEntry/ModalFormEntryLabel.scss | 13 + .../Modal/FormEntry/ModalFormEntryLabel.tsx | 13 + .../PublishAs/ModalFormEntryPublishAs.css | 24 + .../ModalFormEntryPublishAs.spec.tsx | 24 + .../PublishAs/ModalFormEntryPublishAs.tsx | 51 + .../Modal/FormEntry/PublishAs/index.ts | 1 + .../ModalFormEntryRequiredText.spec.tsx | 20 + .../ModalFormEntryRequiredText.tsx | 23 + .../Modal/FormEntry/RequiredText/index.ts | 1 + .../src/components/Modal/FormEntry/index.ts | 10 + .../Modal/FullScreen/ModalFullScreen.css | 34 + .../Modal/FullScreen/ModalFullScreen.tsx | 36 + .../core/src/components/Modal/Modal.css | 42 + .../core/src/components/Modal/Modal.tsx | 85 + .../core/src/components/Modal/ModalExit.scss | 25 + .../core/src/components/Modal/ModalExit.tsx | 17 + .../core/src/components/Modal/ModalForm.scss | 23 + .../core/src/components/Modal/ModalForm.tsx | 20 + .../src/components/Modal/ModalInfoBox.scss | 30 + .../src/components/Modal/ModalInfoBox.tsx | 22 + .../Modal/NameBehavior/ModalNameBehavior.css | 114 + .../NameBehavior/ModalNameBehavior.spec.tsx | 24 + .../Modal/NameBehavior/ModalNameBehavior.tsx | 148 + .../components/Modal/NameBehavior/index.ts | 1 + .../Modal/NewDataset/ModalNewDataset.scss | 75 + .../Modal/NewDataset/ModalNewDataset.tsx | 95 + .../Modal/NewProject/ModalNewProject.css | 174 + .../Modal/NewProject/ModalNewProject.tsx | 264 + .../Modal/NewProject/NewProjectField.tsx | 31 + .../Modal/NewProject/TipWithError.tsx | 22 + .../src/components/Modal/NewProject/types.ts | 8 + .../src/components/Modal/NewProject/utils.ts | 11 + .../ModalPrivateDependencies.css | 48 + .../ModalPrivateDependencies.spec.tsx | 22 + .../ModalPrivateDependencies.tsx | 83 + .../Modal/PrivateDependencies/index.ts | 1 + .../Modal/Release/ModalReleaseBehavior.tsx | 278 + .../Release/ModalReleaseChooseFiles.scss | 57 + .../Modal/Release/ModalReleaseChooseFiles.tsx | 84 + .../Modal/Release/ModalReleaseCreate.tsx | 176 + .../Modal/Release/ModalReleaseUpdate.scss | 14 + .../Modal/Release/ModalReleaseUpdate.spec.tsx | 25 + .../Modal/Release/ModalReleaseUpdate.tsx | 143 + .../Release/VersionPicker/VersionPicker.css | 32 + .../VersionPicker/VersionPicker.spec.tsx | 19 + .../Release/VersionPicker/VersionPicker.tsx | 50 + .../src/components/Modal/Release/index.ts | 3 + .../core/src/components/Modal/Release/util.ts | 6 + .../components/Modal/Share/ModalShare.scss | 115 + .../src/components/Modal/Share/ModalShare.tsx | 74 + .../Share/ModalShareAccessCodeDisclaimer.tsx | 13 + .../Modal/Share/ModalShareByLink.scss | 12 + .../Modal/Share/ModalShareByLink.tsx | 230 + .../Modal/Share/ModalShareCopyButton.css | 9 + .../Modal/Share/ModalShareCopyButton.tsx | 29 + .../Modal/Share/ModalShareEmbed.scss | 80 + .../Modal/Share/ModalShareEmbed.tsx | 152 + .../Modal/Share/ModalShareSelect.scss | 15 + .../Modal/Share/ModalShareSelect.tsx | 19 + .../Modal/Share/ModalShareViews.scss | 3 + .../Modal/Share/ModalShareViews.tsx | 92 + .../core/src/components/Modal/Share/hooks.ts | 140 + .../core/src/components/Modal/Share/utils.ts | 27 + .../components/Modal/Signin/ModalSignin.css | 60 + .../components/Modal/Signin/ModalSignin.tsx | 70 + .../components/Modal/Signup/ModalSignup.css | 91 + .../components/Modal/Signup/ModalSignup.tsx | 67 + .../components/Modal/Split/ModalSplit.scss | 51 + .../src/components/Modal/Split/ModalSplit.tsx | 81 + .../Modal/Split/ModalSplitBottomSection.scss | 47 + .../Modal/Split/ModalSplitBottomSection.tsx | 36 + .../Modal/Split/ModalSplitLegacyTitle.scss | 23 + .../Modal/Split/ModalSplitLegacyTitle.tsx | 13 + .../Modal/TwoColumn/ModalTwoColumn.css | 33 + .../Modal/TwoColumn/ModalTwoColumn.spec.tsx | 19 + .../Modal/TwoColumn/ModalTwoColumn.tsx | 55 + .../src/components/Modal/TwoColumn/index.ts | 1 + .../core/src/components/Modal/index.ts | 18 + .../MonacoContainer/MonacoContainer.css | 11 + .../MonacoContainer/MonacoContainer.spec.tsx | 10 + .../MonacoContainer/MonacoContainer.tsx | 34 + .../src/components/MonacoContainer/index.ts | 1 + .../src/components/OpenInCore/OpenInCore.scss | 62 + .../src/components/OpenInCore/OpenInCore.tsx | 26 + .../PlotViewer/ErrorNotification.tsx | 69 + .../src/components/PlotViewer/OutputPlot.tsx | 397 + .../PlotViewer/OutputPlotCollated.tsx | 124 + .../src/components/PlotViewer/PlotViewer.scss | 130 + .../src/components/PlotViewer/PlotViewer.tsx | 37 + .../PlotViewerCollatedExperiment.tsx | 109 + .../components/PlotViewer/PlotViewerPlots.tsx | 55 + .../PlotViewer/PlotViewerSingleRun.tsx | 159 + .../PlotViewer/PlotViewerTitleContainer.tsx | 10 + .../core/src/components/PlotViewer/analyze.ts | 171 + .../core/src/components/PlotViewer/hooks.ts | 29 + .../core/src/components/PlotViewer/types.ts | 18 + .../core/src/components/PlotViewer/utils.ts | 355 + .../components/ProcessChart/ProcessChart.scss | 51 + .../components/ProcessChart/ProcessChart.tsx | 247 + .../src/components/ProcessChart/utils.tsx | 2 + .../ResizingInputText/ResizingInputText.css | 26 + .../ResizingInputText/ResizingInputText.tsx | 59 + .../ResizingInputText/hooks/index.ts | 1 + .../ResizingInputText/hooks/useMeasurable.ts | 16 + .../src/components/ResizingInputText/index.ts | 1 + .../Button/ResourceListItemButton.css | 50 + .../Button/ResourceListItemButton.spec.tsx | 18 + .../Button/ResourceListItemButton.tsx | 69 + .../ResourceListItem/Button/index.ts | 1 + .../Popup/ResourceListItemPopup.css | 126 + .../Popup/ResourceListItemPopup.spec.tsx | 397 + .../Popup/ResourceListItemPopup.tsx | 318 + .../Table/ResourceListItemPopupTable.css | 47 + .../Table/ResourceListItemPopupTable.tsx | 100 + .../ResourceListItem/Popup/Table/index.ts | 1 + .../ResourceListItem/Popup/index.ts | 1 + .../components/ResourceListItem/Popup/util.ts | 2 + .../ResourceListItem/ResourceListItem.tsx | 58 + .../src/components/ResourceListItem/index.ts | 1 + .../src/components/ScrollFade/ScrollFade.css | 3 + .../src/components/ScrollFade/ScrollFade.tsx | 25 + .../ScrollFade/ScrollFadeShadow.scss | 18 + .../ScrollFade/ScrollFadeShadow.tsx | 30 + .../src/components/Scrollable/Scrollable.css | 59 + .../src/components/Scrollable/Scrollable.tsx | 18 + .../core/src/components/Scrollable/index.tsx | 1 + .../core/src/components/Search/Search.css | 33 + .../src/components/Search/Search.spec.tsx | 27 + .../core/src/components/Search/Search.tsx | 50 + .../core/src/components/Search/index.ts | 1 + .../src/components/ShrinkWrap/ShrinkWrap.css | 6 + .../src/components/ShrinkWrap/ShrinkWrap.tsx | 81 + .../SimpleTooltip/SimpleTooltip.css | 140 + .../SimpleTooltip/SimpleTooltip.spec.tsx | 10 + .../SimpleTooltip/SimpleTooltip.tsx | 281 + .../src/components/SimpleTooltip/context.ts | 4 + .../src/components/SimpleTooltip/index.ts | 1 + .../SimulationRunContextMenu.scss | 47 + .../SimulationRunContextMenu.tsx | 16 + .../SimulationRunContextMenu/index.ts | 1 + .../SimulationRunId/SimulationRunId.scss | 11 + .../SimulationRunId/SimulationRunId.tsx | 24 + .../Controls/Experiments/ExperimentsList.scss | 44 + .../Controls/Experiments/ExperimentsList.tsx | 112 + .../Experiments/ExperimentsListError.css | 54 + .../Experiments/ExperimentsListError.tsx | 42 + .../Controls/Experiments/ExperimentsMenu.css | 110 + .../Controls/Experiments/ExperimentsMenu.tsx | 69 + .../Experiments/ExperimentsRunner.css | 10 + .../Experiments/ExperimentsRunner.tsx | 61 + .../Controls/Experiments/selectors.ts | 19 + .../Controls/PlayPause/PlayPause.tsx | 28 + .../Controls/PlayPause/PlayPauseCompute.tsx | 29 + .../Controls/PlayPause/PlayPausePlayback.tsx | 40 + .../Controls/PlayPause/PlayPauseTooltip.scss | 47 + .../Controls/PlayPause/PlayPauseTooltip.tsx | 63 + .../PlayPauseTooltipModeSwitcher.scss | 48 + .../PlayPauseTooltipModeSwitcher.tsx | 133 + .../PlayPause/PlayPauseTooltipSpeedButton.tsx | 49 + .../SimulationRunner/Controls/Reset.tsx | 44 + .../SimulationRunner/Controls/StepButton.tsx | 39 + .../SimulationRunner/Controls/Timeline.tsx | 73 + .../SimulationRunner/SimulationRunner.css | 287 + .../SimulationRunner/SimulationRunner.tsx | 102 + .../src/components/SimulationRunner/index.ts | 1 + .../SimulationViewer/AgentSceneLazy.tsx | 17 + .../SimulationViewer/AnalysisViewerLazy.tsx | 20 + .../SimulationViewer/GeospatialMapLazy.tsx | 20 + .../LazyTab/SimulationViewerLazyTab.css | 14 + .../LazyTab/SimulationViewerLazyTab.tsx | 40 + .../SimulationViewerPyodideIndicator.tsx | 44 + .../SimulationViewierPyodideIndicator.css | 29 + .../PyodideIndicator/index.ts | 1 + .../SimulationViewer/SimulationViewer.tsx | 291 + .../SimulationViewer/StepExplorerLazy.tsx | 17 + .../src/components/SimulationViewer/index.ts | 1 + .../src/components/SimulationViewer/lazy.ts | 20 + .../components/StepExplorer/StepExplorer.css | 3 + .../components/StepExplorer/StepExplorer.tsx | 194 + .../components/TabActionBar/TabActionBar.scss | 88 + .../components/TabActionBar/TabActionBar.tsx | 110 + .../DiffPanel/TabbedEditorDiffPanel.spec.tsx | 15 + .../DiffPanel/TabbedEditorDiffPanel.tsx | 87 + .../TabbedEditor/DiffPanel/index.ts | 1 + .../Panel/TabbedEditorPanel.spec.tsx | 15 + .../TabbedEditor/Panel/TabbedEditorPanel.tsx | 82 + .../components/TabbedEditor/Panel/index.ts | 1 + .../components/TabbedEditor/hooks/index.ts | 5 + .../TabbedEditor/hooks/useMonacoContainer.tsx | 218 + .../core/src/components/TabbedEditor/index.ts | 8 + .../core/src/components/TabbedEditor/types.ts | 32 + .../components/TabbedEditor/utils/index.ts | 1 + .../TabbedEditor/utils/restoreEditorState.ts | 26 + .../components/Toast/Anchor/ToastAnchor.tsx | 24 + .../core/src/components/Toast/Anchor/index.ts | 1 + .../components/Toast/Button/ToastButton.tsx | 15 + .../core/src/components/Toast/Button/index.ts | 1 + .../ToastLegacySimulationAccess.tsx | 57 + .../Toast/LegacySimulationAccess/index.ts | 1 + .../components/Toast/Manager/ToastManager.css | 17 + .../components/Toast/Manager/ToastManager.tsx | 102 + .../src/components/Toast/Manager/index.ts | 1 + .../Toast/ProjectEditable/ProjectEditable.tsx | 40 + .../ProjectForked/ToastProjectForked.tsx | 19 + .../components/Toast/ProjectForked/index.ts | 1 + .../ProjectPreview/ToastProjectPreview.tsx | 110 + .../components/Toast/ProjectPreview/index.ts | 1 + .../ReadOnlyRelease/ToastReadOnlyRelease.tsx | 39 + .../components/Toast/ReadOnlyRelease/index.ts | 1 + .../ToastReleaseBehaviorSuccess.spec.tsx | 21 + .../ToastReleaseBehaviorSuccess.tsx | 15 + .../Toast/ReleaseBehaviorSuccess/index.ts | 1 + .../ToastReleaseSuccess.spec.tsx | 23 + .../ReleaseSuccess/ToastReleaseSuccess.tsx | 47 + .../components/Toast/ReleaseSuccess/index.ts | 1 + .../src/components/Toast/SimulationToast.tsx | 27 + .../core/src/components/Toast/Toast.css | 78 + .../core/src/components/Toast/Toast.tsx | 28 + .../core/src/components/Toast/index.ts | 11 + .../core/src/components/Toast/types.ts | 13 + .../WrappedSplitterLayout.scss | 16 + .../WrappedSplitterLayout.tsx | 127 + apps/sim-core/packages/core/src/embed.tsx | 26 + .../sim-core/packages/core/src/examples.jsonc | 13012 +++++++++ .../core/src/features/actionObservable.ts | 4 + .../packages/core/src/features/actions.ts | 59 + .../features/analysis/analysisJsonTypes.ts | 204 + .../analysis/analysisJsonValidation.test.ts | 1797 ++ .../analysis/analysisJsonValidation.ts | 423 + .../core/src/features/analysis/errors.ts | 564 + .../src/features/analysis/plotValidations.ts | 425 + .../core/src/features/analysis/utils.ts | 200 + .../core/src/features/analysis/validation.ts | 8 + .../packages/core/src/features/analytics.ts | 72 + .../packages/core/src/features/config.ts | 2 + .../core/src/features/createAppAsyncThunk.ts | 23 + .../core/src/features/examples/adapter.ts | 15 + .../core/src/features/examples/selectors.ts | 16 + .../core/src/features/examples/slice.ts | 26 + .../core/src/features/examples/types.ts | 7 + .../core/src/features/files/adapter.ts | 19 + .../core/src/features/files/behaviorKeys.ts | 274 + .../packages/core/src/features/files/enums.ts | 10 + .../packages/core/src/features/files/hooks.ts | 245 + .../core/src/features/files/selectors.spec.ts | 866 + .../core/src/features/files/selectors.ts | 470 + .../core/src/features/files/slice.spec.ts | 1139 + .../packages/core/src/features/files/slice.ts | 1248 + .../packages/core/src/features/files/types.ts | 122 + .../packages/core/src/features/files/utils.ts | 466 + .../core/src/features/files/validate.ts | 39 + .../makeSelectAnalysisSelectorForSimIds.ts | 38 + .../core/src/features/middleware/analysis.ts | 22 + .../core/src/features/middleware/index.ts | 1 + .../src/features/middleware/localStorage.ts | 67 + .../core/src/features/middleware/queue.ts | 107 + .../core/src/features/middleware/tracking.ts | 47 + .../addGitConflictMarkersDecorator.tsx | 426 + .../core/src/features/monaco/index.ts | 7 + .../core/src/features/monaco/monaco.ts | 244 + .../core/src/features/project/mocks.ts | 109 + .../core/src/features/project/observables.ts | 15 + .../core/src/features/project/selectors.ts | 139 + .../core/src/features/project/slice.ts | 363 + .../core/src/features/project/thunks.ts | 56 + .../core/src/features/project/types.ts | 149 + .../core/src/features/project/utils.ts | 149 + .../core/src/features/project/validation.ts | 87 + .../packages/core/src/features/rootReducer.ts | 21 + .../packages/core/src/features/scopes.ts | 419 + .../core/src/features/search/index.ts | 3 + .../core/src/features/search/selectors.ts | 12 + .../core/src/features/search/slice.spec.ts | 21 + .../core/src/features/search/slice.ts | 23 + .../core/src/features/search/types.ts | 3 + .../packages/core/src/features/selectors.ts | 48 + .../features/simulator/actionObservable.ts | 4 + .../core/src/features/simulator/context.tsx | 50 + .../historicCloudExperimentProvider.ts | 283 + .../simulator/simulate/analysisMiddleware.ts | 219 + .../simulator/simulate/buildprovider.ts | 35 + .../src/features/simulator/simulate/enum.ts | 4 + .../simulator/simulate/historyAdapter.ts | 38 + .../simulator/simulate/historySubscriber.ts | 102 + .../features/simulator/simulate/middleware.ts | 83 + .../simulator/simulate/playbackSubscriber.ts | 102 + .../features/simulator/simulate/provider.ts | 122 + .../simulator/simulate/queueExperiment.ts | 657 + .../simulator/simulate/runningSubscriber.ts | 107 + .../features/simulator/simulate/selectors.ts | 306 + .../src/features/simulator/simulate/slice.ts | 1883 ++ .../src/features/simulator/simulate/sync.ts | 159 + .../src/features/simulator/simulate/target.ts | 25 + .../src/features/simulator/simulate/thunks.ts | 353 + .../src/features/simulator/simulate/types.ts | 170 + .../src/features/simulator/simulate/util.ts | 315 + .../core/src/features/simulator/store.ts | 29 + .../core/src/features/simulator/types.ts | 19 + .../packages/core/src/features/store.ts | 67 + .../packages/core/src/features/subscribe.ts | 10 + .../features/subscribers/autoSaveSubscribe.ts | 93 + .../packages/core/src/features/thunks.ts | 206 + .../packages/core/src/features/toast/enums.ts | 10 + .../core/src/features/toast/index.spec.ts | 34 + .../packages/core/src/features/toast/index.ts | 3 + .../core/src/features/toast/selectors.ts | 16 + .../packages/core/src/features/toast/slice.ts | 79 + .../packages/core/src/features/toast/types.ts | 6 + .../packages/core/src/features/types.ts | 17 + .../core/src/features/user/adapter.ts | 16 + .../packages/core/src/features/user/index.ts | 11 + .../packages/core/src/features/user/local.ts | 9 + .../core/src/features/user/selectors.ts | 47 + .../packages/core/src/features/user/slice.ts | 80 + .../packages/core/src/features/user/thunks.ts | 48 + .../packages/core/src/features/user/types.ts | 17 + .../packages/core/src/features/user/utils.ts | 7 + .../packages/core/src/features/utils.ts | 21 + .../core/src/features/viewer/enums.ts | 8 + .../core/src/features/viewer/index.ts | 16 + .../core/src/features/viewer/selectors.ts | 65 + .../core/src/features/viewer/slice.ts | 224 + .../core/src/features/viewer/types.ts | 31 + .../core/src/features/viewer/utils.ts | 36 + .../packages/core/src/hooks/shouldUnload.ts | 24 + .../core/src/hooks/useAbortingDispatch.ts | 41 + .../useAnalysisSrcForCurrentActivityItem.ts | 17 + .../packages/core/src/hooks/useCanHover.ts | 46 + .../core/src/hooks/useCancellableDebounce.ts | 31 + .../core/src/hooks/useClipboardWriteText.ts | 19 + .../packages/core/src/hooks/useHover.ts | 29 + .../core/src/hooks/useKeyboardShortcuts.ts | 83 + .../core/src/hooks/useLocalStorage/index.ts | 6 + .../hooks/useLocalStorage/useLocalStorage.ts | 43 + .../core/src/hooks/useLocalStorage/utils.ts | 50 + .../core/src/hooks/useOnClickOutside.ts | 35 + .../core/src/hooks/useParameterisedUi.ts | 45 + .../core/src/hooks/useParseAnalysis.ts | 18 + .../packages/core/src/hooks/usePromise.ts | 45 + .../packages/core/src/hooks/useRefState.ts | 27 + .../core/src/hooks/useRemSize/index.ts | 1 + .../core/src/hooks/useRemSize/useRemSize.tsx | 33 + .../core/src/hooks/useResizeObserver/types.ts | 48 + .../useResizeObserver/useResizeObserver.ts | 157 + .../packages/core/src/hooks/useSafeOnClose.ts | 17 + .../core/src/hooks/useSafeQueryParams.ts | 27 + .../packages/core/src/hooks/useSaveOrFork.ts | 51 + .../packages/core/src/hooks/useScrollState.ts | 107 + .../core/src/hooks/useSyncAnimations.ts | 163 + apps/sim-core/packages/core/src/index.html | 39 + apps/sim-core/packages/core/src/index.tsx | 48 + apps/sim-core/packages/core/src/metaTags.json | 4 + apps/sim-core/packages/core/src/routes.ts | 71 + apps/sim-core/packages/core/src/setupTests.ts | 45 + .../packages/core/src/shared/README.md | 14 + .../packages/core/src/shared/scopes.ts | 34 + apps/sim-core/packages/core/src/styles.css | 366 + .../packages/core/src/styles/fonts.css | 3 + .../core/src/styles/fonts/apercu-mono.css | 59 + .../packages/core/src/styles/fonts/apercu.css | 149 + .../packages/core/src/styles/fonts/inter.css | 188 + .../packages/core/src/styles/splitter.css | 60 + .../core/src/util/api/graphql-schema.json | 21863 ++++++++++++++++ .../packages/core/src/util/api/index.ts | 36 + .../packages/core/src/util/api/paths.spec.ts | 9 + .../packages/core/src/util/api/paths.ts | 24 + .../util/api/queries/addDatasetToProject.ts | 52 + .../core/src/util/api/queries/basicUser.ts | 73 + .../src/util/api/queries/bootstrapQuery.ts | 162 + .../util/api/queries/canUserEditProject.ts | 28 + .../src/util/api/queries/commitActions.ts | 100 + .../core/src/util/api/queries/coreVersions.ts | 5 + .../util/api/queries/createDatasetQuery.ts | 56 + .../api/queries/createNewSimulationProject.ts | 36 + .../api/queries/createReleaseWithUpdate.ts | 61 + .../util/api/queries/exampleSimulations.ts | 43 + .../src/util/api/queries/fetchDependencies.ts | 91 + .../queries/forkAndReleaseBehaviorsQuery.ts | 83 + .../src/util/api/queries/forkProjectQuery.ts | 67 + .../util/api/queries/getOnboardingProject.ts | 36 + .../src/util/api/queries/getReleaseMeta.ts | 50 + .../core/src/util/api/queries/index.ts | 12 + .../api/queries/linkableProjectByLegacyId.ts | 37 + .../core/src/util/api/queries/myProjects.ts | 45 + .../util/api/queries/partialProjectByPath.ts | 47 + .../src/util/api/queries/projectHistory.ts | 136 + .../util/api/queries/projectReleaseTags.ts | 24 + .../src/util/api/queries/promoteToLive.ts | 10 + .../src/util/api/queries/registerEvents.ts | 10 + .../requestPrivateProjectAccessCode.ts | 22 + .../api/queries/searchResourceProjects.ts | 74 + .../core/src/util/api/queries/tourShowcase.ts | 103 + .../src/util/api/queries/trackTourProgress.ts | 20 + .../api/queries/unpreparedProjectByPath.ts | 95 + .../core/src/util/api/queries/userForks.ts | 20 + .../core/src/util/api/queries/utils.ts | 21 + .../packages/core/src/util/api/query.ts | 136 + .../packages/core/src/util/api/types.ts | 64 + .../packages/core/src/util/api/utils.ts | 20 + .../core/src/util/builtinSimulations.ts | 179 + .../packages/core/src/util/countMatches.ts | 5 + .../core/src/util/defaultJsBehaviorSrc.ts | 9 + .../packages/core/src/util/descByUpdatedAt.ts | 6 + .../core/src/util/exhaustMapWithTrailing.ts | 78 + .../packages/core/src/util/files/enums.ts | 16 + .../packages/core/src/util/files/index.ts | 2 + .../core/src/util/files/parse.spec.ts | 66 + .../packages/core/src/util/files/parse.ts | 95 + .../packages/core/src/util/files/rename.ts | 18 + .../packages/core/src/util/files/types.ts | 13 + .../packages/core/src/util/formatNumber.ts | 3 + .../packages/core/src/util/fromStore.ts | 11 + .../packages/core/src/util/getEmbedParams.ts | 32 + .../core/src/util/getSafeQueryParams.ts | 14 + .../packages/core/src/util/initSentry.ts | 50 + .../packages/core/src/util/isSafari.ts | 15 + .../core/src/util/localStorageProjectKey.ts | 5 + .../packages/core/src/util/material.ts | 12 + apps/sim-core/packages/core/src/util/math.ts | 3 + .../util/monaco-config/completions-hstd.d.ts | 885 + .../src/util/monaco-config/completions.d.ts | 88 + .../core/src/util/monaco-config/index.ts | 19 + .../core/src/util/monaco-config/monaco-js.ts | 26 + .../src/util/monaco-config/monaco-json.ts | 30 + .../src/util/monaco-config/monaco-theme.ts | 15 + .../util/monaco-config/schemas/analysis.ts | 103 + .../src/util/monaco-config/schemas/globals.ts | 59 + .../src/util/monaco-config/schemas/init.ts | 58 + .../core/src/util/nextNonClashingName.ts | 22 + .../packages/core/src/util/palette.ts | 72 + .../core/src/util/parseAccessCodeInParams.ts | 62 + .../core/src/util/parseBehaviorKeysQuery.ts | 49 + .../packages/core/src/util/postFormData.ts | 27 + .../core/src/util/prepareFormDataWithFile.ts | 16 + .../packages/core/src/util/pyodideEnabled.ts | 12 + .../core/src/util/resizeObserverPromise.ts | 17 + .../core/src/util/safeParseJsonTracked.ts | 31 + .../core/src/util/setSignalTimeout.ts | 21 + .../core/src/util/simulation/mock-coreweb.ts | 6 + apps/sim-core/packages/core/src/util/theme.ts | 80 + apps/sim-core/packages/core/src/util/types.ts | 60 + .../packages/core/src/util/validation.ts | 17 + .../packages/core/src/util/withSignal.ts | 24 + .../packages/core/src/util/yieldToBrowser.ts | 23 + .../core/src/workers/analyzer-worker/index.ts | 13 + .../src/workers/analyzer-worker/tsconfig.json | 12 + .../src/workers/simulation-worker/index.ts | 41 + .../workers/simulation-worker/tsconfig.json | 13 + apps/sim-core/packages/core/tests/README.md | 63 + .../packages/core/tests/e2e/globals.test.js | 95 + .../core/tests/e2e/logged_in/main.test.js | 231 + .../tests/e2e/sanity_checks/errors.test.js | 25 + .../core/tests/e2e/sanity_checks/menu.test.js | 528 + .../tests/e2e/sanity_checks/metadata.test.js | 49 + .../e2e/sanity_checks/slack_button.test.js | 16 + .../sim-core/packages/core/tests/e2e/utils.js | 296 + .../core/tests/jest-puppeteer.config.js | 21 + .../packages/core/tests/jest.config.js | 10 + .../packages/core/tests/package-lock.json | 12599 +++++++++ .../sim-core/packages/core/tests/package.json | 18 + apps/sim-core/packages/core/tsconfig.json | 11 + apps/sim-core/packages/core/webpack.config.js | 342 + .../sim-core/packages/engine-types/Cargo.lock | 345 + .../sim-core/packages/engine-types/Cargo.toml | 12 + apps/sim-core/packages/engine-types/README.md | 3 + .../packages/engine-types/src/distance.rs | 60 + .../packages/engine-types/src/error.rs | 77 + .../sim-core/packages/engine-types/src/lib.rs | 13 + .../packages/engine-types/src/message.rs | 421 + .../packages/engine-types/src/properties.rs | 283 + .../packages/engine-types/src/state.rs | 1141 + .../packages/engine-types/src/topology.rs | 149 + .../sim-core/packages/engine-types/src/vec.rs | 316 + .../packages/engine-types/src/worker.rs | 159 + .../packages/engine-web/.eslintignore | 1 + apps/sim-core/packages/engine-web/.eslintrc | 10 + apps/sim-core/packages/engine-web/.gitignore | 2 + apps/sim-core/packages/engine-web/Cargo.toml | 24 + apps/sim-core/packages/engine-web/README.md | 5 + .../packages/engine-web/babel.config.js | 11 + .../sim-core/packages/engine-web/package.json | 46 + .../engine-web/rust/agentstatewrapper.rs | 101 + .../packages/engine-web/rust/behavior.rs | 59 + .../engine-web/rust/contextwrapper.rs | 39 + apps/sim-core/packages/engine-web/rust/lib.rs | 33 + .../engine-web/rust/messagehandler.rs | 107 + .../packages/engine-web/rust/simulation.rs | 106 + .../sim-core/packages/engine-web/rust/util.rs | 62 + .../src/engine-web/analysis/analyzer.ts | 53 + .../src/engine-web/analysis/evalAnalysis.ts | 339 + .../src/engine-web/analysis/index.ts | 4 + .../src/engine-web/analysis/plots.ts | 40 + .../src/engine-web/analysis/wrapper.ts | 26 + .../src/engine-web/experiments/definition.ts | 274 + .../src/engine-web/experiments/index.ts | 3 + .../src/engine-web/experiments/jstat.d.ts | 26 + .../src/engine-web/experiments/listener.ts | 95 + .../src/engine-web/experiments/montecarlo.ts | 67 + .../src/engine-web/experiments/types.ts | 211 + .../engine-web/src/engine-web/index.ts | 4 + .../src/engine-web/runners/actions.ts | 241 + .../src/engine-web/runners/cloud-runner.ts | 327 + .../src/engine-web/runners/index.ts | 134 + .../src/engine-web/runners/wasm-runner.ts | 142 + .../src/engine-web/runners/web-runner.ts | 148 + .../src/engine-web/runners/worker-runner.ts | 33 + .../src/engine-web/simulation/EvalError.ts | 156 + .../src/engine-web/simulation/behavior.ts | 94 + .../src/engine-web/simulation/buildpython.ts | 154 + .../src/engine-web/simulation/dataset.ts | 124 + .../src/engine-web/simulation/index.ts | 7 + .../src/engine-web/simulation/initializer.ts | 103 + .../engine-web/simulation/messagehandler.ts | 32 + .../src/engine-web/simulation/python/index.ts | 252 + .../engine-web/simulation/python/pyodide.d.ts | 3 + .../engine-web/simulation/python/pyodide.js | 951 + .../simulation/python/pyodideTypes.d.ts | 66 + .../engine-web/simulation/python/wrappers.ts | 77 + .../src/engine-web/simulation/simFromSrc.ts | 85 + .../src/engine-web/simulation/types.ts | 68 + .../src/engine-web/simulation/util.ts | 12 + .../engine-web/src/glue/AgentStateProxy.ts | 87 + .../engine-web/src/glue/JsCustomBehavior.ts | 126 + .../src/glue/JsCustomBehaviors.spec.ts | 162 + .../engine-web/src/glue/JsCustomBehaviors.ts | 97 + .../engine-web/src/glue/JsInitializer.ts | 28 + .../engine-web/src/glue/JsMessageHandler.ts | 27 + .../src/glue/JsMessageHandlers.spec.ts | 492 + .../engine-web/src/glue/JsMessageHandlers.ts | 18 + .../src/glue/MessageHandlerStateWrapper.ts | 13 + .../packages/engine-web/src/glue/index.ts | 6 + .../packages/engine-web/src/glue/types.ts | 112 + .../sim-core/packages/engine-web/src/index.ts | 3 + .../engine-web/src/simulation/Analysis.ts | 26 + .../engine-web/src/simulation/Analyzer.ts | 39 + .../src/simulation/Simulation.spec.ts | 63 + .../engine-web/src/simulation/Simulation.ts | 124 + .../engine-web/src/simulation/index.ts | 6 + .../engine-web/src/simulation/types.ts | 16 + .../engine-web/src/simulation/utils.ts | 3 + .../packages/engine-web/src/stdlib/index.ts | 2 + .../engine-web/src/stdlib/py/pystdlib.py | 21 + .../engine-web/src/stdlib/py/pystdlib.ts | 28 + .../engine-web/src/stdlib/rust/stdlib.rs | 3 + .../engine-web/src/stdlib/ts/stdlib.d.ts | 0 .../engine-web/src/stdlib/ts/stdlib.spec.ts | 226 + .../engine-web/src/stdlib/ts/stdlib.ts | 596 + .../packages/engine-web/tsconfig.json | 15 + .../packages/engine-web/wasm/.gitignore | 2 + .../packages/engine-web/wasm/package.json | 10 + .../packages/engine-web/webpack.config.js | 28 + apps/sim-core/packages/engine/.gitignore | 4 + apps/sim-core/packages/engine/Cargo.toml | 34 + apps/sim-core/packages/engine/README.md | 7 + .../packages/engine/benches/benchmark.rs | 98 + .../packages/engine/examples/montepi.rs | 189 + .../packages/engine/examples/montepi_fixed.rs | 207 + .../engine/examples/reproduction_rate.rs | 197 + .../packages/engine/src/behaviors/action.rs | 61 + .../packages/engine/src/behaviors/age.rs | 14 + .../packages/engine/src/behaviors/builtin.rs | 16 + .../engine/src/behaviors/collision.rs | 48 + .../packages/engine/src/behaviors/control.rs | 56 + .../packages/engine/src/behaviors/conway.rs | 42 + .../packages/engine/src/behaviors/counter.rs | 24 + .../engine/src/behaviors/create_agents.rs | 28 + .../engine/src/behaviors/create_grids.rs | 59 + .../engine/src/behaviors/create_scatters.rs | 68 + .../engine/src/behaviors/create_stacks.rs | 84 + .../packages/engine/src/behaviors/decay.rs | 38 + .../engine/src/behaviors/diffusion.rs | 91 + .../packages/engine/src/behaviors/forces.rs | 25 + .../packages/engine/src/behaviors/gravity.rs | 20 + .../packages/engine/src/behaviors/mod.rs | 1003 + .../engine/src/behaviors/move_in_direction.rs | 16 + .../src/behaviors/orient_toward_value.rs | 81 + .../packages/engine/src/behaviors/physics.rs | 41 + .../src/behaviors/random_away_movement.rs | 21 + .../engine/src/behaviors/random_movement.rs | 68 + .../engine/src/behaviors/remove_self.rs | 10 + .../engine/src/behaviors/reproduce.rs | 31 + .../packages/engine/src/behaviors/spring.rs | 55 + .../packages/engine/src/behaviors/update_q.rs | 80 + .../engine/src/behaviors/viral_spread.rs | 39 + apps/sim-core/packages/engine/src/cfg/mod.rs | 34 + .../packages/engine/src/cfg/simulation.rs | 77 + apps/sim-core/packages/engine/src/lib.rs | 45 + .../engine/src/message_handlers/builtin.rs | 39 + .../engine/src/message_handlers/mod.rs | 68 + .../packages/engine/src/runtimes/behaviors.rs | 1 + .../packages/engine/src/runtimes/messaging.rs | 136 + .../packages/engine/src/runtimes/mod.rs | 140 + .../packages/engine/src/runtimes/neighbors.rs | 85 + .../packages/engine/src/runtimes/sim.rs | 72 + apps/sim-core/packages/engine/src/sim.rs | 133 + .../packages/engine/src/sim/adjacency.rs | 518 + .../packages/engine/tests/agent_state.rs | 32 + .../packages/engine/tests/behaviors.rs | 41 + apps/sim-core/packages/engine/tests/common.rs | 41 + .../packages/engine/tests/integration.rs | 1467 ++ .../engine/vendor/kdtree-rust/.gitignore | 9 + .../engine/vendor/kdtree-rust/Cargo.toml | 24 + .../engine/vendor/kdtree-rust/LICENSE | 24 + .../engine/vendor/kdtree-rust/README.md | 94 + .../engine/vendor/kdtree-rust/src/bench.rs | 99 + .../vendor/kdtree-rust/src/kdtree/bounds.rs | 108 + .../vendor/kdtree-rust/src/kdtree/distance.rs | 37 + .../vendor/kdtree-rust/src/kdtree/mod.rs | 475 + .../kdtree-rust/src/kdtree/partition.rs | 275 + .../kdtree-rust/src/kdtree/test_common.rs | 74 + .../engine/vendor/kdtree-rust/src/lib.rs | 8 + .../kdtree-rust/tests/integration_tests.rs | 156 + apps/sim-core/packages/utils/.eslintrc | 10 + apps/sim-core/packages/utils/.gitignore | 1 + apps/sim-core/packages/utils/README.md | 4 + apps/sim-core/packages/utils/package.json | 23 + .../utils/src/datasets/fetchDataset.ts | 83 + .../utils/src/parsers/parseCsvAsJson.ts | 46 + .../validators/validateModelDescription.ts | 9 + .../utils/src/validators/validateModelName.ts | 9 + .../utils/src/validators/validateModelPath.ts | 9 + apps/sim-core/packages/utils/tsconfig.json | 19 + apps/sim-core/packages/utils/yarn.lock | 13 + apps/sim-core/scripts/README.md | 10 + apps/sim-core/scripts/builtin_behaviors | 1 + apps/sim-core/scripts/install-dependencies.sh | 12 + .../scripts/integration_tests/.gitignore | 4 + .../scripts/integration_tests/README.md | 11 + .../scripts/integration_tests/cypress.json | 4 + .../cypress/fixtures/example.json | 5 + .../cypress/integration/sample_spec.js | 60 + .../cypress/plugins/index.js | 21 + .../cypress/support/commands.js | 26 + .../cypress/support/index.js | 20 + .../scripts/integration_tests/package.json | 13 + apps/sim-core/scripts/preinstall.js | 14 + .../scripts/upload_builtin_behaviors.ts | 112 + .../upload_builtin_behaviors_readme.md | 1 + apps/sim-core/tsconfig.json | 83 + apps/sim-core/vercel.json | 15 + apps/sim-core/yarn.lock | 17915 +++++++++++++ 1369 files changed, 157710 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 apps/sim-core/.gitignore create mode 100644 apps/sim-core/Cargo.lock create mode 100644 apps/sim-core/Cargo.toml create mode 100644 apps/sim-core/example_projects/ant-foraging.zip create mode 100644 apps/sim-core/example_projects/boids-3d.zip create mode 100644 apps/sim-core/example_projects/city-infection-model.zip create mode 100644 apps/sim-core/example_projects/connection-example.zip create mode 100644 apps/sim-core/example_projects/empty-project.zip create mode 100644 apps/sim-core/example_projects/empty-template-project.zip create mode 100644 apps/sim-core/example_projects/model-market.zip create mode 100644 apps/sim-core/example_projects/published-display-behaviors.zip create mode 100644 apps/sim-core/example_projects/rainfall.zip create mode 100644 apps/sim-core/example_projects/rumor-mill-public-health-practices.zip create mode 100644 apps/sim-core/example_projects/sugarscape.zip create mode 100644 apps/sim-core/example_projects/virus-mutation-and-drug-resistance.zip create mode 100644 apps/sim-core/example_projects/warehouse-logistics.zip create mode 100644 apps/sim-core/example_projects/wildfires-regrowth.zip create mode 100644 apps/sim-core/package.json create mode 100644 apps/sim-core/packages/core/.eslintrc create mode 100644 apps/sim-core/packages/core/.gitignore create mode 100644 apps/sim-core/packages/core/.prettierignore create mode 100644 apps/sim-core/packages/core/README.md create mode 100644 apps/sim-core/packages/core/babel.config.js create mode 100644 apps/sim-core/packages/core/codegen.yml create mode 100644 apps/sim-core/packages/core/package.json create mode 100755 apps/sim-core/packages/core/scripts/cli/index.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/generateFile.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/generateFiles.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/index.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/parseArgs.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/parseIcon.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/templates.ts create mode 100644 apps/sim-core/packages/core/scripts/cli/utils/validateIcon.ts create mode 100644 apps/sim-core/packages/core/scripts/deploy.ts create mode 100644 apps/sim-core/packages/core/scripts/tsconfig.json create mode 100644 apps/sim-core/packages/core/scripts/types.d.ts create mode 100644 apps/sim-core/packages/core/site.d.ts create mode 100644 apps/sim-core/packages/core/src/boot.ts create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroup.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroup.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroupSection.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroupSection.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroupSectionItem.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroupSectionItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroupTitle.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryGroup/ActivityHistoryGroupTitle.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItem.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItemCommit.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItemCommit.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItemCommitGroup.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItemTooltip.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryItemTooltip.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryRelease.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryRelease.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryRowSpacer.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistoryRowSpacer.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityTime.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ActivityTime.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/AgentHistoryItemIcons.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/AgentHistoryItemIcons.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroup.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroup.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupIconDots.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupIconDots.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupRun.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupRun.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupSectionItem.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupSectionItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/ExperimentGroupSections.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/ExperimentGroup/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/Inspector/Inspector.css create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/Inspector/Inspector.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/SingleRun/ActivityHistorySingleRun.scss create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/SingleRun/ActivityHistorySingleRun.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/hooks.tsx create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ActivityHistory/util.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/AgentScene.css create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/AgentScene.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/README.md create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/AgentMesh.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/AgentRenderer.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/Controls.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/HoveredAgent.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/NetworkEdges.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/SceneSettings.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/components/Stage.tsx create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/state/SceneState.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/state/resetViewer.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/state/updateTransitionMap.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/state/util.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/util/anim.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/util/builtinmodels.ts create mode 100644 apps/sim-core/packages/core/src/components/AgentScene/util/geometry-loader.ts create mode 100644 apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.scss create mode 100644 apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/AnalysisViewerActionButtons.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.scss create mode 100644 apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/HelpParagraph.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.scss create mode 100644 apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/OutputMetricsTab.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/PlotsTab.tsx create mode 100644 apps/sim-core/packages/core/src/components/Analysis/TabListActionButtons.scss create mode 100644 apps/sim-core/packages/core/src/components/Analysis/modals.test.ts create mode 100644 apps/sim-core/packages/core/src/components/Analysis/modals.ts create mode 100644 apps/sim-core/packages/core/src/components/Analysis/types.ts create mode 100644 apps/sim-core/packages/core/src/components/Analysis/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/App/App.css create mode 100644 apps/sim-core/packages/core/src/components/App/App.tsx create mode 100644 apps/sim-core/packages/core/src/components/App/index.ts create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.css create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.scss create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldFormPopoverOptions.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.css create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.scss create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.scss create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.scss create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.tsx create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/project.ts create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/types.ts create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/BehaviorKeys/validate.ts create mode 100644 apps/sim-core/packages/core/src/components/DataLoader/DataLoader.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataLoader/hooks/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DataLoader/hooks/useDataLoaderParser.ts create mode 100644 apps/sim-core/packages/core/src/components/DataLoader/types.ts create mode 100644 apps/sim-core/packages/core/src/components/DataLoader/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.css create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Body/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.css create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Cell/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DataTable/DataTable.css create mode 100644 apps/sim-core/packages/core/src/components/DataTable/DataTable.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/DataTable.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.css create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Head/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.css create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Pagination/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.tsx create mode 100644 apps/sim-core/packages/core/src/components/DataTable/Row/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DataTable/index.ts create mode 100644 apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.css create mode 100644 apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.tsx create mode 100644 apps/sim-core/packages/core/src/components/DiscordWidget/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/Dropdown.scss create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/Dropdown.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/Dropdown.tsx create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.tsx create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/MenuList/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Dropdown/types.ts create mode 100644 apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.scss create mode 100644 apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.tsx create mode 100644 apps/sim-core/packages/core/src/components/EmbedApp/bootEmbed.tsx create mode 100644 apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.css create mode 100644 apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.tsx create mode 100644 apps/sim-core/packages/core/src/components/ErrorBoundary/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.css create mode 100644 apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.tsx create mode 100644 apps/sim-core/packages/core/src/components/ErrorDetails/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Anchor/FancyAnchor.css create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Anchor/FancyAnchor.tsx create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Anchor/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Button/FancyButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Button/FancyButtonAsyncTask.scss create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Button/FancyButtonAsyncTask.tsx create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Button/FancyButtonWithDropdown.scss create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Button/FancyButtonWithDropdown.tsx create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Button/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Fancy/Fancy.scss create mode 100644 apps/sim-core/packages/core/src/components/Fancy/index.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Builtin/FileBannerBuiltin.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Builtin/FileBannerBuiltin.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Builtin/FileBannerBuiltin.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Builtin/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Choose/FileBannerChoose.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Choose/FileBannerChoose.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Choose/FileBannerChoose.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Choose/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/FileBanner.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/PythonSafari/FileBannerPythonSafari.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/PythonSafari/FileBannerPythonSafari.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/PythonSafari/FileBannerPythonSafari.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/PythonSafari/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Shared/FileBannerShared.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Shared/FileBannerShared.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Shared/FileBannerShared.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Shared/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/SignIn/FileBannerSignIn.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/SignIn/FileBannerSignIn.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Upgrade/FileBannerUpgrade.css create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Upgrade/FileBannerUpgrade.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Upgrade/FileBannerUpgrade.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Upgrade/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Wrapper/FileBannerWrapper.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Wrapper/FileBannerWrapper.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/Wrapper/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileBanner/index.ts create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileName.scss create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileName.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileNameWithIcon.scss create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileNameWithIcon.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileNameWithShortname.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileNameWithShortnameIcon.tsx create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileNameWithShortnameInner.css create mode 100644 apps/sim-core/packages/core/src/components/FileName/FileNameWithShortnameInner.tsx create mode 100644 apps/sim-core/packages/core/src/components/FontsPreloader/FontsPreloader.tsx create mode 100644 apps/sim-core/packages/core/src/components/FontsPreloader/index.ts create mode 100644 apps/sim-core/packages/core/src/components/GeospatialMap/GeospatialMap.css create mode 100644 apps/sim-core/packages/core/src/components/GeospatialMap/GeospatialMap.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsArray.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsEditor.scss create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsEditor.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsObject.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsRow.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsRowContainer.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/GlobalsRowField.tsx create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/index.ts create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/types.ts create mode 100644 apps/sim-core/packages/core/src/components/GlobalsEditor/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/HashCoreAccessGate.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/HashCoreAccessGate.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/NotFound/HashCoreAccessGateNotFound.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/NotFound/HashCoreAccessGateNotFound.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/NotFound/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/enums.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/types.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/AccessGate/util.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Aside/HashCoreAside.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Aside/HashCoreAside.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Aside/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Console/HashCoreConsole.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Console/HashCoreConsole.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Console/HashCoreConsoleAlert.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Console/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/ContextMenu/HashCoreContextMenu.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/ContextMenu/HashCoreContextMenu.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/ContextMenu/HashCoreContextMenu.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/ContextMenu/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditor.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditor.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorBehaviorKeysFileAction.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorFile.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Editor/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Editor/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/EditorContainer/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFilePending.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchContainer.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/README.md create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/charCode.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/replacePattern.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/search.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/strings.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/reducer.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/types.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/Search/util.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/hooks/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useModalNameBehavior.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useName.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useNameNewBehaviorModal.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useRenameBehaviorModal.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Files/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/HashCore.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/HashCoreHeaderMenuExperiments.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.scss create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/HashCoreHeaderMenuHelp.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/HashCoreHeaderMenuView.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useClickOutside.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useMenu.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/Menu/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/ShareButton/HashCoreHeaderShareButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Header/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Main/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Main/util.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/List/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/HashCoreResourcesSearchableIndex.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Resources/selectors.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Section/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepAgents.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDatasets.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepIntro.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPause.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlay.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlots.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepRefresh.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/steps.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/Step/util.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/react-shepherd-wrapper.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Tour/util.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Viewer/HashCoreViewer.css create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Viewer/HashCoreViewer.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashCore/Viewer/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/useInstructionReceiver.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/utils/getUserOrgs.ts create mode 100644 apps/sim-core/packages/core/src/components/HashCore/utils/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/DefaultProject.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/Fork.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/LegacySimulation.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/NewProject.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/NotFound.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/Onboard.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/Project.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/Signin.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/Signup.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/index.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/routes.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/templates/empty.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/templates/starter.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/templates/templates.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/Effect/templates/types.ts create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/HashRouter.tsx create mode 100644 apps/sim-core/packages/core/src/components/HashRouter/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/AccountMultiple/IconAccountMultiple.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AccountMultiple/IconAccountMultiple.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AccountMultiple/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/AddDatapoint/IconAddDatapoint.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AddDatapoint/IconAddDatapoint.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AddDatapoint/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Alert/IconAlert.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Alert/IconAlert.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Alert/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/AlertOutline/IconAlertOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AlertOutline/IconAlertOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AlertOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowDownDrop/IconArrowDownDrop.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowDownDrop/IconArrowDownDrop.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowDownDrop/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowLeftBold/IconArrowLeftBold.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowLeftBold/IconArrowLeftBold.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowLeftBold/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowRightBold/IconArrowRightBold.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowRightBold/IconArrowRightBold.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ArrowRightBold/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/AutoFix/IconAutoFix.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AutoFix/IconAutoFix.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/AutoFix/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Beaker/IconBeaker.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Beaker/IconBeaker.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Beaker/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Brain/IconBrain.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Brain/IconBrain.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Brain/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Cancel/IconCancel.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Cancel/IconCancel.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Cancel/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartBarStacked/IconChartBarStacked.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartBarStacked/IconChartBarStacked.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartBarStacked/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartLine/IconChartLine.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartLine/IconChartLine.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartLine/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartLineVariant/IconChartLineVariant.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartLineVariant/IconChartLineVariant.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChartLineVariant/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Check/IconCheck.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Check/IconCheck.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Check/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/CheckboxMarkedCircleOutline/IconCheckboxMarkedCircleOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CheckboxMarkedCircleOutline/IconCheckboxMarkedCircleOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CheckboxMarkedCircleOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/CheckboxMarkedOutline/IconCheckboxMarkedOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CheckboxMarkedOutline/IconCheckboxMarkedOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CheckboxMarkedOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChevronRight/IconChevronRight.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChevronRight/IconChevronRight.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ChevronRight/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Close/IconClose.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Close/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Cloud/IconCloud.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Cloud/IconCloud.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Cloud/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/CodeTagsCheck/IconCodeTagsCheck.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CodeTagsCheck/IconCodeTagsCheck.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CodeTagsCheck/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ContentCopy/IconContentCopy.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ContentCopy/IconContentCopy.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ContentCopy/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ContentDuplicate/IconContentDuplicate.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ContentDuplicate/IconContentDuplicate.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ContentDuplicate/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Copy/IconCopy.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Copy/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/CreateDashboard/IconCreateDashboard.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CreateDashboard/IconCreateDashboard.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CreateDashboard/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/CreatePlot/IconCreatePlot.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CreatePlot/IconCreatePlot.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CreatePlot/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/CubeUnfolded/IconCubeUnfolded.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CubeUnfolded/IconCubeUnfolded.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/CubeUnfolded/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Desktop/IconDesktop.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Desktop/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/DirectionsFork/IconDirectionsFork.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DirectionsFork/IconDirectionsFork.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DirectionsFork/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Discord/IconDiscord.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Discord/IconDiscord.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Discord/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/DotsHorizontal/IconDotsHorizontal.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DotsHorizontal/IconDotsHorizontal.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DotsHorizontal/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/DotsVertical/IconDotsVertical.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DotsVertical/IconDotsVertical.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DotsVertical/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Download/IconDownload.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Download/IconDownload.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Download/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/DragVertical/IconDragVertical.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DragVertical/IconDragVertical.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/DragVertical/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Earth/IconEarth.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Earth/IconEarth.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Earth/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ExperimentsCreate/IconExperimentsCreate.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ExperimentsCreate/IconExperimentsCreate.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ExperimentsCreate/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/ExperimentsRun/IconExperimentsRun.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ExperimentsRun/IconExperimentsRun.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/ExperimentsRun/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Eye/IconEye.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Eye/IconEye.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Eye/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/EyeOutline/IconEyeOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/EyeOutline/IconEyeOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/EyeOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/FileFind/IconFileFind.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FileFind/IconFileFind.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FileFind/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/FileOutline/IconFileOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FileOutline/IconFileOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FileOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/FilePlus/IconFilePlus.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FilePlus/IconFilePlus.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FilePlus/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Filter/IconFilter.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Filter/IconFilter.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Filter/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Folder/IconFolder.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Folder/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/FolderLock/IconFolderLock.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FolderLock/IconFolderLock.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FolderLock/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/FolderOpen/IconFolderOpen.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/FolderOpen/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/HCoreMono/IconHCoreMono.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HCoreMono/IconHCoreMono.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HCoreMono/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/HIndex/IconHIndex.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HIndex/IconHIndex.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HIndex/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/HelpCircle/IconHelpCircle.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HelpCircle/IconHelpCircle.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HelpCircle/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/HelpCircleOutline/IconHelpCircleOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HelpCircleOutline/IconHelpCircleOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/HelpCircleOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Icon.css create mode 100644 apps/sim-core/packages/core/src/components/Icon/Import/IconImport.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Import/IconImport.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Import/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/InformationOutline/IconInformationOutline.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/InformationOutline/IconInformationOutline.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/InformationOutline/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/KeyPlus/IconKeyPlus.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/KeyPlus/IconKeyPlus.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/KeyPlus/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/KeyboardReturn/IconKeyboardReturn.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/KeyboardReturn/IconKeyboardReturn.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/KeyboardReturn/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Loading/IconLoading.scss create mode 100644 apps/sim-core/packages/core/src/components/Icon/Loading/IconLoading.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Loading/LazyIconLoading.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Loading/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Loading/types.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Lock/IconLock.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Lock/IconLock.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Lock/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Magnify/IconMagnify.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Magnify/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/MenuDown/IconMenuDown.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/MenuDown/IconMenuDown.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/MenuDown/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/More/IconMore.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/More/IconMore.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/More/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/OpenInNew/IconOpenInNew.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/OpenInNew/IconOpenInNew.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/OpenInNew/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/PackageDown/IconPackageDown.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/PackageDown/IconPackageDown.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/PackageDown/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Pause/IconPause.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Pause/IconPause.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Pause/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Pencil/IconPencil.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Pencil/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Plus/IconPlus.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Plus/IconPlus.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Plus/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/PresentationPause/IconPresentationPause.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/PresentationPause/IconPresentationPause.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/PresentationPause/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/PresentationPlay/IconPresentationPlay.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/PresentationPlay/IconPresentationPlay.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/PresentationPlay/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Python/IconPython.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Python/IconPython.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Python/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Restart/IconRestart.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Restart/IconRestart.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Restart/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/RunFast/IconRunFast.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/RunFast/IconRunFast.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/RunFast/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Rust/IconRust.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Rust/IconRust.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Rust/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Server/IconServer.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Server/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Settings/IconSettings.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Settings/IconSettings.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Settings/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Sparkles/IconSparkles.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Sparkles/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Spinner/IconSpinner.css create mode 100644 apps/sim-core/packages/core/src/components/Icon/Spinner/IconSpinner.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Spinner/IconSpinner.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Spinner/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Stop/IconStop.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Stop/IconStop.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Stop/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Sync/IconSync.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Sync/IconSync.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Sync/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/TableAdd/IconTableAdd.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/TableAdd/IconTableAdd.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/TableAdd/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/TableLarge/IconTableLarge.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/TableLarge/IconTableLarge.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/TableLarge/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Trash/IconTrash.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Trash/IconTrash.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Trash/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Tree/IconTree.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Tree/IconTree.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Tree/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Update/IconUpdate.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Update/IconUpdate.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Update/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/Upload/IconUpload.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Upload/IconUpload.tsx create mode 100644 apps/sim-core/packages/core/src/components/Icon/Upload/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Icon/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Checkbox/CheckboxInput.css create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Checkbox/CheckboxInput.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/EnumInput/EnumInput.scss create mode 100644 apps/sim-core/packages/core/src/components/Inputs/EnumInput/EnumInput.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/EnumInput/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Radio/RadioInput.scss create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Radio/RadioInput.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/RoundedTextInput/RoundedTextInput.css create mode 100644 apps/sim-core/packages/core/src/components/Inputs/RoundedTextInput/RoundedTextInput.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/RoundedTextInput/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Select/RoundedSelect.css create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Select/RoundedSelect.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Select/Select.css create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Select/Select.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/Select/types.ts create mode 100644 apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.scss create mode 100644 apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.tsx create mode 100644 apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Inputs/index.ts create mode 100644 apps/sim-core/packages/core/src/components/KeepInView/KeepInView.tsx create mode 100644 apps/sim-core/packages/core/src/components/KeepInView/index.ts create mode 100644 apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.css create mode 100644 apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.tsx create mode 100644 apps/sim-core/packages/core/src/components/LabeledInputRadio/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Link/Link.tsx create mode 100644 apps/sim-core/packages/core/src/components/Link/LinkBehavior.tsx create mode 100644 apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.css create mode 100644 apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.tsx create mode 100644 apps/sim-core/packages/core/src/components/LoadingIcon/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Logo/Logo.css create mode 100644 apps/sim-core/packages/core/src/components/Logo/Logo.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Logo/Logo.tsx create mode 100644 apps/sim-core/packages/core/src/components/Logo/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/OperationItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Analysis/YAxisItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/BigModal.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/BigModal.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/CloudUsage/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/CloudUsage/useModalCloudUsage.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Confirm/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentTypeTooltip.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/types.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/utils.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.spec.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useKeywords.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useLicenses.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/usePublishAs.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useSubjects.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FormEntry/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Modal.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/Modal.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/ModalExit.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/ModalExit.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/ModalForm.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/ModalForm.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NameBehavior/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewProject/NewProjectField.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewProject/TipWithError.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewProject/types.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/NewProject/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/PrivateDependencies/ModalPrivateDependencies.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/PrivateDependencies/ModalPrivateDependencies.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/PrivateDependencies/ModalPrivateDependencies.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/PrivateDependencies/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseBehavior.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseChooseFiles.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseChooseFiles.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseCreate.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseUpdate.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseUpdate.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/ModalReleaseUpdate.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/VersionPicker/VersionPicker.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/VersionPicker/VersionPicker.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/VersionPicker/VersionPicker.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Release/util.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShare.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShare.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareAccessCodeDisclaimer.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareByLink.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareByLink.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareCopyButton.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareCopyButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareEmbed.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareEmbed.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareSelect.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareSelect.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareViews.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/ModalShareViews.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Share/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/Signin/ModalSignin.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/Signin/ModalSignin.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Signup/ModalSignup.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/Signup/ModalSignup.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Split/ModalSplit.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Split/ModalSplit.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Split/ModalSplitBottomSection.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Split/ModalSplitBottomSection.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/Split/ModalSplitLegacyTitle.scss create mode 100644 apps/sim-core/packages/core/src/components/Modal/Split/ModalSplitLegacyTitle.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/TwoColumn/ModalTwoColumn.css create mode 100644 apps/sim-core/packages/core/src/components/Modal/TwoColumn/ModalTwoColumn.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/TwoColumn/ModalTwoColumn.tsx create mode 100644 apps/sim-core/packages/core/src/components/Modal/TwoColumn/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Modal/index.ts create mode 100644 apps/sim-core/packages/core/src/components/MonacoContainer/MonacoContainer.css create mode 100644 apps/sim-core/packages/core/src/components/MonacoContainer/MonacoContainer.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/MonacoContainer/MonacoContainer.tsx create mode 100644 apps/sim-core/packages/core/src/components/MonacoContainer/index.ts create mode 100644 apps/sim-core/packages/core/src/components/OpenInCore/OpenInCore.scss create mode 100644 apps/sim-core/packages/core/src/components/OpenInCore/OpenInCore.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/ErrorNotification.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/OutputPlot.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/OutputPlotCollated.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/PlotViewer.scss create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/PlotViewer.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/PlotViewerCollatedExperiment.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/PlotViewerPlots.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/PlotViewerSingleRun.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/PlotViewerTitleContainer.tsx create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/analyze.ts create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/hooks.ts create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/types.ts create mode 100644 apps/sim-core/packages/core/src/components/PlotViewer/utils.ts create mode 100644 apps/sim-core/packages/core/src/components/ProcessChart/ProcessChart.scss create mode 100644 apps/sim-core/packages/core/src/components/ProcessChart/ProcessChart.tsx create mode 100644 apps/sim-core/packages/core/src/components/ProcessChart/utils.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResizingInputText/ResizingInputText.css create mode 100644 apps/sim-core/packages/core/src/components/ResizingInputText/ResizingInputText.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResizingInputText/hooks/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ResizingInputText/hooks/useMeasurable.ts create mode 100644 apps/sim-core/packages/core/src/components/ResizingInputText/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Button/ResourceListItemButton.css create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Button/ResourceListItemButton.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Button/ResourceListItemButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Button/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/ResourceListItemPopup.css create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/ResourceListItemPopup.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/ResourceListItemPopup.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/Table/ResourceListItemPopupTable.css create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/Table/ResourceListItemPopupTable.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/Table/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/Popup/util.ts create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/ResourceListItem.tsx create mode 100644 apps/sim-core/packages/core/src/components/ResourceListItem/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ScrollFade/ScrollFade.css create mode 100644 apps/sim-core/packages/core/src/components/ScrollFade/ScrollFade.tsx create mode 100644 apps/sim-core/packages/core/src/components/ScrollFade/ScrollFadeShadow.scss create mode 100644 apps/sim-core/packages/core/src/components/ScrollFade/ScrollFadeShadow.tsx create mode 100644 apps/sim-core/packages/core/src/components/Scrollable/Scrollable.css create mode 100644 apps/sim-core/packages/core/src/components/Scrollable/Scrollable.tsx create mode 100644 apps/sim-core/packages/core/src/components/Scrollable/index.tsx create mode 100644 apps/sim-core/packages/core/src/components/Search/Search.css create mode 100644 apps/sim-core/packages/core/src/components/Search/Search.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Search/Search.tsx create mode 100644 apps/sim-core/packages/core/src/components/Search/index.ts create mode 100644 apps/sim-core/packages/core/src/components/ShrinkWrap/ShrinkWrap.css create mode 100644 apps/sim-core/packages/core/src/components/ShrinkWrap/ShrinkWrap.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimpleTooltip/SimpleTooltip.css create mode 100644 apps/sim-core/packages/core/src/components/SimpleTooltip/SimpleTooltip.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimpleTooltip/SimpleTooltip.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimpleTooltip/context.ts create mode 100644 apps/sim-core/packages/core/src/components/SimpleTooltip/index.ts create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunContextMenu/SimulationRunContextMenu.scss create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunContextMenu/SimulationRunContextMenu.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunContextMenu/index.ts create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunId/SimulationRunId.scss create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunId/SimulationRunId.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsList.scss create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsList.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsListError.css create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsListError.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsMenu.css create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsMenu.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsRunner.css create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/ExperimentsRunner.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Experiments/selectors.ts create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPause.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPauseCompute.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPausePlayback.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPauseTooltip.scss create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPauseTooltip.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPauseTooltipModeSwitcher.scss create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPauseTooltipModeSwitcher.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/PlayPause/PlayPauseTooltipSpeedButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Reset.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/StepButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/Controls/Timeline.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/SimulationRunner.css create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/SimulationRunner.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationRunner/index.ts create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/AgentSceneLazy.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/AnalysisViewerLazy.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/GeospatialMapLazy.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/LazyTab/SimulationViewerLazyTab.css create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/LazyTab/SimulationViewerLazyTab.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/PyodideIndicator/SimulationViewerPyodideIndicator.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/PyodideIndicator/SimulationViewierPyodideIndicator.css create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/PyodideIndicator/index.ts create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/SimulationViewer.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/StepExplorerLazy.tsx create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/index.ts create mode 100644 apps/sim-core/packages/core/src/components/SimulationViewer/lazy.ts create mode 100644 apps/sim-core/packages/core/src/components/StepExplorer/StepExplorer.css create mode 100644 apps/sim-core/packages/core/src/components/StepExplorer/StepExplorer.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabActionBar/TabActionBar.scss create mode 100644 apps/sim-core/packages/core/src/components/TabActionBar/TabActionBar.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/DiffPanel/TabbedEditorDiffPanel.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/DiffPanel/TabbedEditorDiffPanel.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/DiffPanel/index.ts create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/Panel/TabbedEditorPanel.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/Panel/TabbedEditorPanel.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/Panel/index.ts create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/hooks/index.ts create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/hooks/useMonacoContainer.tsx create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/index.ts create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/types.ts create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/utils/index.ts create mode 100644 apps/sim-core/packages/core/src/components/TabbedEditor/utils/restoreEditorState.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/Anchor/ToastAnchor.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/Anchor/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/Button/ToastButton.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/Button/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/LegacySimulationAccess/ToastLegacySimulationAccess.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/LegacySimulationAccess/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/Manager/ToastManager.css create mode 100644 apps/sim-core/packages/core/src/components/Toast/Manager/ToastManager.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/Manager/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/ProjectEditable/ProjectEditable.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ProjectForked/ToastProjectForked.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ProjectForked/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/ProjectPreview/ToastProjectPreview.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ProjectPreview/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReadOnlyRelease/ToastReadOnlyRelease.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReadOnlyRelease/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReleaseBehaviorSuccess/ToastReleaseBehaviorSuccess.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReleaseBehaviorSuccess/ToastReleaseBehaviorSuccess.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReleaseBehaviorSuccess/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReleaseSuccess/ToastReleaseSuccess.spec.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReleaseSuccess/ToastReleaseSuccess.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/ReleaseSuccess/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/SimulationToast.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/Toast.css create mode 100644 apps/sim-core/packages/core/src/components/Toast/Toast.tsx create mode 100644 apps/sim-core/packages/core/src/components/Toast/index.ts create mode 100644 apps/sim-core/packages/core/src/components/Toast/types.ts create mode 100644 apps/sim-core/packages/core/src/components/WrappedSplitterLayout/WrappedSplitterLayout.scss create mode 100644 apps/sim-core/packages/core/src/components/WrappedSplitterLayout/WrappedSplitterLayout.tsx create mode 100644 apps/sim-core/packages/core/src/embed.tsx create mode 100644 apps/sim-core/packages/core/src/examples.jsonc create mode 100644 apps/sim-core/packages/core/src/features/actionObservable.ts create mode 100644 apps/sim-core/packages/core/src/features/actions.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/analysisJsonTypes.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/analysisJsonValidation.test.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/analysisJsonValidation.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/errors.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/plotValidations.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/utils.ts create mode 100644 apps/sim-core/packages/core/src/features/analysis/validation.ts create mode 100644 apps/sim-core/packages/core/src/features/analytics.ts create mode 100644 apps/sim-core/packages/core/src/features/config.ts create mode 100644 apps/sim-core/packages/core/src/features/createAppAsyncThunk.ts create mode 100644 apps/sim-core/packages/core/src/features/examples/adapter.ts create mode 100644 apps/sim-core/packages/core/src/features/examples/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/examples/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/examples/types.ts create mode 100644 apps/sim-core/packages/core/src/features/files/adapter.ts create mode 100644 apps/sim-core/packages/core/src/features/files/behaviorKeys.ts create mode 100644 apps/sim-core/packages/core/src/features/files/enums.ts create mode 100644 apps/sim-core/packages/core/src/features/files/hooks.ts create mode 100644 apps/sim-core/packages/core/src/features/files/selectors.spec.ts create mode 100644 apps/sim-core/packages/core/src/features/files/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/files/slice.spec.ts create mode 100644 apps/sim-core/packages/core/src/features/files/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/files/types.ts create mode 100644 apps/sim-core/packages/core/src/features/files/utils.ts create mode 100644 apps/sim-core/packages/core/src/features/files/validate.ts create mode 100644 apps/sim-core/packages/core/src/features/makeSelectAnalysisSelectorForSimIds.ts create mode 100644 apps/sim-core/packages/core/src/features/middleware/analysis.ts create mode 100644 apps/sim-core/packages/core/src/features/middleware/index.ts create mode 100644 apps/sim-core/packages/core/src/features/middleware/localStorage.ts create mode 100644 apps/sim-core/packages/core/src/features/middleware/queue.ts create mode 100644 apps/sim-core/packages/core/src/features/middleware/tracking.ts create mode 100644 apps/sim-core/packages/core/src/features/monaco/decorators/addGitConflictMarkersDecorator.tsx create mode 100644 apps/sim-core/packages/core/src/features/monaco/index.ts create mode 100644 apps/sim-core/packages/core/src/features/monaco/monaco.ts create mode 100644 apps/sim-core/packages/core/src/features/project/mocks.ts create mode 100644 apps/sim-core/packages/core/src/features/project/observables.ts create mode 100644 apps/sim-core/packages/core/src/features/project/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/project/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/project/thunks.ts create mode 100644 apps/sim-core/packages/core/src/features/project/types.ts create mode 100644 apps/sim-core/packages/core/src/features/project/utils.ts create mode 100644 apps/sim-core/packages/core/src/features/project/validation.ts create mode 100644 apps/sim-core/packages/core/src/features/rootReducer.ts create mode 100644 apps/sim-core/packages/core/src/features/scopes.ts create mode 100644 apps/sim-core/packages/core/src/features/search/index.ts create mode 100644 apps/sim-core/packages/core/src/features/search/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/search/slice.spec.ts create mode 100644 apps/sim-core/packages/core/src/features/search/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/search/types.ts create mode 100644 apps/sim-core/packages/core/src/features/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/actionObservable.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/context.tsx create mode 100644 apps/sim-core/packages/core/src/features/simulator/historicCloudExperimentProvider.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/analysisMiddleware.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/buildprovider.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/enum.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/historyAdapter.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/historySubscriber.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/middleware.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/playbackSubscriber.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/provider.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/queueExperiment.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/runningSubscriber.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/sync.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/target.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/thunks.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/types.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/simulate/util.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/store.ts create mode 100644 apps/sim-core/packages/core/src/features/simulator/types.ts create mode 100644 apps/sim-core/packages/core/src/features/store.ts create mode 100644 apps/sim-core/packages/core/src/features/subscribe.ts create mode 100644 apps/sim-core/packages/core/src/features/subscribers/autoSaveSubscribe.ts create mode 100644 apps/sim-core/packages/core/src/features/thunks.ts create mode 100644 apps/sim-core/packages/core/src/features/toast/enums.ts create mode 100644 apps/sim-core/packages/core/src/features/toast/index.spec.ts create mode 100644 apps/sim-core/packages/core/src/features/toast/index.ts create mode 100644 apps/sim-core/packages/core/src/features/toast/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/toast/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/toast/types.ts create mode 100644 apps/sim-core/packages/core/src/features/types.ts create mode 100644 apps/sim-core/packages/core/src/features/user/adapter.ts create mode 100644 apps/sim-core/packages/core/src/features/user/index.ts create mode 100644 apps/sim-core/packages/core/src/features/user/local.ts create mode 100644 apps/sim-core/packages/core/src/features/user/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/user/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/user/thunks.ts create mode 100644 apps/sim-core/packages/core/src/features/user/types.ts create mode 100644 apps/sim-core/packages/core/src/features/user/utils.ts create mode 100644 apps/sim-core/packages/core/src/features/utils.ts create mode 100644 apps/sim-core/packages/core/src/features/viewer/enums.ts create mode 100644 apps/sim-core/packages/core/src/features/viewer/index.ts create mode 100644 apps/sim-core/packages/core/src/features/viewer/selectors.ts create mode 100644 apps/sim-core/packages/core/src/features/viewer/slice.ts create mode 100644 apps/sim-core/packages/core/src/features/viewer/types.ts create mode 100644 apps/sim-core/packages/core/src/features/viewer/utils.ts create mode 100644 apps/sim-core/packages/core/src/hooks/shouldUnload.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useAbortingDispatch.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useAnalysisSrcForCurrentActivityItem.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useCanHover.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useCancellableDebounce.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useClipboardWriteText.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useHover.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useKeyboardShortcuts.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useLocalStorage/index.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useLocalStorage/useLocalStorage.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useLocalStorage/utils.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useOnClickOutside.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useParameterisedUi.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useParseAnalysis.ts create mode 100644 apps/sim-core/packages/core/src/hooks/usePromise.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useRefState.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useRemSize/index.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useRemSize/useRemSize.tsx create mode 100644 apps/sim-core/packages/core/src/hooks/useResizeObserver/types.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useResizeObserver/useResizeObserver.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useSafeOnClose.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useSafeQueryParams.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useSaveOrFork.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useScrollState.ts create mode 100644 apps/sim-core/packages/core/src/hooks/useSyncAnimations.ts create mode 100644 apps/sim-core/packages/core/src/index.html create mode 100644 apps/sim-core/packages/core/src/index.tsx create mode 100644 apps/sim-core/packages/core/src/metaTags.json create mode 100644 apps/sim-core/packages/core/src/routes.ts create mode 100644 apps/sim-core/packages/core/src/setupTests.ts create mode 100644 apps/sim-core/packages/core/src/shared/README.md create mode 100644 apps/sim-core/packages/core/src/shared/scopes.ts create mode 100644 apps/sim-core/packages/core/src/styles.css create mode 100644 apps/sim-core/packages/core/src/styles/fonts.css create mode 100644 apps/sim-core/packages/core/src/styles/fonts/apercu-mono.css create mode 100644 apps/sim-core/packages/core/src/styles/fonts/apercu.css create mode 100644 apps/sim-core/packages/core/src/styles/fonts/inter.css create mode 100644 apps/sim-core/packages/core/src/styles/splitter.css create mode 100644 apps/sim-core/packages/core/src/util/api/graphql-schema.json create mode 100644 apps/sim-core/packages/core/src/util/api/index.ts create mode 100644 apps/sim-core/packages/core/src/util/api/paths.spec.ts create mode 100644 apps/sim-core/packages/core/src/util/api/paths.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/addDatasetToProject.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/basicUser.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/bootstrapQuery.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/canUserEditProject.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/commitActions.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/coreVersions.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/createDatasetQuery.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/createNewSimulationProject.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/createReleaseWithUpdate.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/exampleSimulations.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/fetchDependencies.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/forkAndReleaseBehaviorsQuery.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/forkProjectQuery.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/getOnboardingProject.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/getReleaseMeta.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/index.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/linkableProjectByLegacyId.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/myProjects.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/partialProjectByPath.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/projectHistory.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/projectReleaseTags.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/promoteToLive.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/registerEvents.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/requestPrivateProjectAccessCode.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/searchResourceProjects.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/tourShowcase.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/trackTourProgress.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/unpreparedProjectByPath.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/userForks.ts create mode 100644 apps/sim-core/packages/core/src/util/api/queries/utils.ts create mode 100644 apps/sim-core/packages/core/src/util/api/query.ts create mode 100644 apps/sim-core/packages/core/src/util/api/types.ts create mode 100644 apps/sim-core/packages/core/src/util/api/utils.ts create mode 100644 apps/sim-core/packages/core/src/util/builtinSimulations.ts create mode 100644 apps/sim-core/packages/core/src/util/countMatches.ts create mode 100644 apps/sim-core/packages/core/src/util/defaultJsBehaviorSrc.ts create mode 100644 apps/sim-core/packages/core/src/util/descByUpdatedAt.ts create mode 100644 apps/sim-core/packages/core/src/util/exhaustMapWithTrailing.ts create mode 100644 apps/sim-core/packages/core/src/util/files/enums.ts create mode 100644 apps/sim-core/packages/core/src/util/files/index.ts create mode 100644 apps/sim-core/packages/core/src/util/files/parse.spec.ts create mode 100644 apps/sim-core/packages/core/src/util/files/parse.ts create mode 100644 apps/sim-core/packages/core/src/util/files/rename.ts create mode 100644 apps/sim-core/packages/core/src/util/files/types.ts create mode 100644 apps/sim-core/packages/core/src/util/formatNumber.ts create mode 100644 apps/sim-core/packages/core/src/util/fromStore.ts create mode 100644 apps/sim-core/packages/core/src/util/getEmbedParams.ts create mode 100644 apps/sim-core/packages/core/src/util/getSafeQueryParams.ts create mode 100644 apps/sim-core/packages/core/src/util/initSentry.ts create mode 100644 apps/sim-core/packages/core/src/util/isSafari.ts create mode 100644 apps/sim-core/packages/core/src/util/localStorageProjectKey.ts create mode 100644 apps/sim-core/packages/core/src/util/material.ts create mode 100644 apps/sim-core/packages/core/src/util/math.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/completions-hstd.d.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/completions.d.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/index.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/monaco-js.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/monaco-json.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/monaco-theme.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/schemas/analysis.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/schemas/globals.ts create mode 100644 apps/sim-core/packages/core/src/util/monaco-config/schemas/init.ts create mode 100644 apps/sim-core/packages/core/src/util/nextNonClashingName.ts create mode 100644 apps/sim-core/packages/core/src/util/palette.ts create mode 100644 apps/sim-core/packages/core/src/util/parseAccessCodeInParams.ts create mode 100644 apps/sim-core/packages/core/src/util/parseBehaviorKeysQuery.ts create mode 100644 apps/sim-core/packages/core/src/util/postFormData.ts create mode 100644 apps/sim-core/packages/core/src/util/prepareFormDataWithFile.ts create mode 100644 apps/sim-core/packages/core/src/util/pyodideEnabled.ts create mode 100644 apps/sim-core/packages/core/src/util/resizeObserverPromise.ts create mode 100644 apps/sim-core/packages/core/src/util/safeParseJsonTracked.ts create mode 100644 apps/sim-core/packages/core/src/util/setSignalTimeout.ts create mode 100644 apps/sim-core/packages/core/src/util/simulation/mock-coreweb.ts create mode 100644 apps/sim-core/packages/core/src/util/theme.ts create mode 100644 apps/sim-core/packages/core/src/util/types.ts create mode 100644 apps/sim-core/packages/core/src/util/validation.ts create mode 100644 apps/sim-core/packages/core/src/util/withSignal.ts create mode 100644 apps/sim-core/packages/core/src/util/yieldToBrowser.ts create mode 100644 apps/sim-core/packages/core/src/workers/analyzer-worker/index.ts create mode 100644 apps/sim-core/packages/core/src/workers/analyzer-worker/tsconfig.json create mode 100644 apps/sim-core/packages/core/src/workers/simulation-worker/index.ts create mode 100644 apps/sim-core/packages/core/src/workers/simulation-worker/tsconfig.json create mode 100644 apps/sim-core/packages/core/tests/README.md create mode 100644 apps/sim-core/packages/core/tests/e2e/globals.test.js create mode 100644 apps/sim-core/packages/core/tests/e2e/logged_in/main.test.js create mode 100644 apps/sim-core/packages/core/tests/e2e/sanity_checks/errors.test.js create mode 100644 apps/sim-core/packages/core/tests/e2e/sanity_checks/menu.test.js create mode 100644 apps/sim-core/packages/core/tests/e2e/sanity_checks/metadata.test.js create mode 100644 apps/sim-core/packages/core/tests/e2e/sanity_checks/slack_button.test.js create mode 100644 apps/sim-core/packages/core/tests/e2e/utils.js create mode 100644 apps/sim-core/packages/core/tests/jest-puppeteer.config.js create mode 100644 apps/sim-core/packages/core/tests/jest.config.js create mode 100644 apps/sim-core/packages/core/tests/package-lock.json create mode 100644 apps/sim-core/packages/core/tests/package.json create mode 100644 apps/sim-core/packages/core/tsconfig.json create mode 100644 apps/sim-core/packages/core/webpack.config.js create mode 100644 apps/sim-core/packages/engine-types/Cargo.lock create mode 100644 apps/sim-core/packages/engine-types/Cargo.toml create mode 100644 apps/sim-core/packages/engine-types/README.md create mode 100644 apps/sim-core/packages/engine-types/src/distance.rs create mode 100644 apps/sim-core/packages/engine-types/src/error.rs create mode 100644 apps/sim-core/packages/engine-types/src/lib.rs create mode 100644 apps/sim-core/packages/engine-types/src/message.rs create mode 100644 apps/sim-core/packages/engine-types/src/properties.rs create mode 100644 apps/sim-core/packages/engine-types/src/state.rs create mode 100644 apps/sim-core/packages/engine-types/src/topology.rs create mode 100644 apps/sim-core/packages/engine-types/src/vec.rs create mode 100644 apps/sim-core/packages/engine-types/src/worker.rs create mode 100644 apps/sim-core/packages/engine-web/.eslintignore create mode 100644 apps/sim-core/packages/engine-web/.eslintrc create mode 100644 apps/sim-core/packages/engine-web/.gitignore create mode 100644 apps/sim-core/packages/engine-web/Cargo.toml create mode 100644 apps/sim-core/packages/engine-web/README.md create mode 100644 apps/sim-core/packages/engine-web/babel.config.js create mode 100644 apps/sim-core/packages/engine-web/package.json create mode 100644 apps/sim-core/packages/engine-web/rust/agentstatewrapper.rs create mode 100644 apps/sim-core/packages/engine-web/rust/behavior.rs create mode 100644 apps/sim-core/packages/engine-web/rust/contextwrapper.rs create mode 100644 apps/sim-core/packages/engine-web/rust/lib.rs create mode 100644 apps/sim-core/packages/engine-web/rust/messagehandler.rs create mode 100644 apps/sim-core/packages/engine-web/rust/simulation.rs create mode 100644 apps/sim-core/packages/engine-web/rust/util.rs create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/analysis/analyzer.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/analysis/evalAnalysis.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/analysis/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/analysis/plots.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/analysis/wrapper.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/experiments/definition.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/experiments/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/experiments/jstat.d.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/experiments/listener.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/experiments/montecarlo.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/experiments/types.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/runners/actions.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/runners/cloud-runner.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/runners/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/runners/wasm-runner.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/runners/web-runner.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/runners/worker-runner.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/EvalError.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/behavior.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/buildpython.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/dataset.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/initializer.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/messagehandler.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/python/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/python/pyodide.d.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/python/pyodide.js create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/python/pyodideTypes.d.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/python/wrappers.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/simFromSrc.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/types.ts create mode 100644 apps/sim-core/packages/engine-web/src/engine-web/simulation/util.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/AgentStateProxy.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsCustomBehavior.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsCustomBehaviors.spec.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsCustomBehaviors.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsInitializer.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsMessageHandler.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsMessageHandlers.spec.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/JsMessageHandlers.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/MessageHandlerStateWrapper.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/glue/types.ts create mode 100644 apps/sim-core/packages/engine-web/src/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/Analysis.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/Analyzer.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/Simulation.spec.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/Simulation.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/types.ts create mode 100644 apps/sim-core/packages/engine-web/src/simulation/utils.ts create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/index.ts create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/py/pystdlib.py create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/py/pystdlib.ts create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/rust/stdlib.rs create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/ts/stdlib.d.ts create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/ts/stdlib.spec.ts create mode 100644 apps/sim-core/packages/engine-web/src/stdlib/ts/stdlib.ts create mode 100644 apps/sim-core/packages/engine-web/tsconfig.json create mode 100644 apps/sim-core/packages/engine-web/wasm/.gitignore create mode 100644 apps/sim-core/packages/engine-web/wasm/package.json create mode 100644 apps/sim-core/packages/engine-web/webpack.config.js create mode 100644 apps/sim-core/packages/engine/.gitignore create mode 100644 apps/sim-core/packages/engine/Cargo.toml create mode 100644 apps/sim-core/packages/engine/README.md create mode 100644 apps/sim-core/packages/engine/benches/benchmark.rs create mode 100644 apps/sim-core/packages/engine/examples/montepi.rs create mode 100644 apps/sim-core/packages/engine/examples/montepi_fixed.rs create mode 100644 apps/sim-core/packages/engine/examples/reproduction_rate.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/action.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/age.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/builtin.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/collision.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/control.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/conway.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/counter.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/create_agents.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/create_grids.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/create_scatters.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/create_stacks.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/decay.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/diffusion.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/forces.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/gravity.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/mod.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/move_in_direction.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/orient_toward_value.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/physics.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/random_away_movement.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/random_movement.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/remove_self.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/reproduce.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/spring.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/update_q.rs create mode 100644 apps/sim-core/packages/engine/src/behaviors/viral_spread.rs create mode 100644 apps/sim-core/packages/engine/src/cfg/mod.rs create mode 100644 apps/sim-core/packages/engine/src/cfg/simulation.rs create mode 100644 apps/sim-core/packages/engine/src/lib.rs create mode 100644 apps/sim-core/packages/engine/src/message_handlers/builtin.rs create mode 100644 apps/sim-core/packages/engine/src/message_handlers/mod.rs create mode 100644 apps/sim-core/packages/engine/src/runtimes/behaviors.rs create mode 100644 apps/sim-core/packages/engine/src/runtimes/messaging.rs create mode 100644 apps/sim-core/packages/engine/src/runtimes/mod.rs create mode 100644 apps/sim-core/packages/engine/src/runtimes/neighbors.rs create mode 100644 apps/sim-core/packages/engine/src/runtimes/sim.rs create mode 100644 apps/sim-core/packages/engine/src/sim.rs create mode 100644 apps/sim-core/packages/engine/src/sim/adjacency.rs create mode 100644 apps/sim-core/packages/engine/tests/agent_state.rs create mode 100644 apps/sim-core/packages/engine/tests/behaviors.rs create mode 100644 apps/sim-core/packages/engine/tests/common.rs create mode 100644 apps/sim-core/packages/engine/tests/integration.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/.gitignore create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/Cargo.toml create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/LICENSE create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/README.md create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/bench.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/kdtree/bounds.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/kdtree/distance.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/kdtree/mod.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/kdtree/partition.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/kdtree/test_common.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/src/lib.rs create mode 100644 apps/sim-core/packages/engine/vendor/kdtree-rust/tests/integration_tests.rs create mode 100644 apps/sim-core/packages/utils/.eslintrc create mode 100644 apps/sim-core/packages/utils/.gitignore create mode 100644 apps/sim-core/packages/utils/README.md create mode 100644 apps/sim-core/packages/utils/package.json create mode 100644 apps/sim-core/packages/utils/src/datasets/fetchDataset.ts create mode 100644 apps/sim-core/packages/utils/src/parsers/parseCsvAsJson.ts create mode 100644 apps/sim-core/packages/utils/src/validators/validateModelDescription.ts create mode 100644 apps/sim-core/packages/utils/src/validators/validateModelName.ts create mode 100644 apps/sim-core/packages/utils/src/validators/validateModelPath.ts create mode 100644 apps/sim-core/packages/utils/tsconfig.json create mode 100644 apps/sim-core/packages/utils/yarn.lock create mode 100644 apps/sim-core/scripts/README.md create mode 120000 apps/sim-core/scripts/builtin_behaviors create mode 100755 apps/sim-core/scripts/install-dependencies.sh create mode 100644 apps/sim-core/scripts/integration_tests/.gitignore create mode 100644 apps/sim-core/scripts/integration_tests/README.md create mode 100644 apps/sim-core/scripts/integration_tests/cypress.json create mode 100644 apps/sim-core/scripts/integration_tests/cypress/fixtures/example.json create mode 100644 apps/sim-core/scripts/integration_tests/cypress/integration/sample_spec.js create mode 100644 apps/sim-core/scripts/integration_tests/cypress/plugins/index.js create mode 100644 apps/sim-core/scripts/integration_tests/cypress/support/commands.js create mode 100644 apps/sim-core/scripts/integration_tests/cypress/support/index.js create mode 100644 apps/sim-core/scripts/integration_tests/package.json create mode 100644 apps/sim-core/scripts/preinstall.js create mode 100644 apps/sim-core/scripts/upload_builtin_behaviors.ts create mode 100644 apps/sim-core/scripts/upload_builtin_behaviors_readme.md create mode 100644 apps/sim-core/tsconfig.json create mode 100644 apps/sim-core/vercel.json create mode 100644 apps/sim-core/yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..341e591 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +.awcache +.env +.env.local +.env.sh +.idea +.ipynb_checkpoints +.next +.old/ +.vscode/ +dist/ +node_modules/ +target/ +test_artifacts/ \ No newline at end of file diff --git a/apps/sim-core/.gitignore b/apps/sim-core/.gitignore new file mode 100644 index 0000000..93b87e4 --- /dev/null +++ b/apps/sim-core/.gitignore @@ -0,0 +1,27 @@ +# generated types +packages/hashai/src/lib/graphql/types.ts +packages/hashai/src/lib/graphql/fragmentTypes.json +.old + +# Terraform +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* +**/terraform.tfstate.d/* +.circleci/terraform/tfapply + +# Python bin for AWS Lambda function +.circleci/terraform/lambda/six.py +.circleci/terraform/lambda/*/ +.circleci/terraform/lambda.zip + +# Crash log files +crash.log +yarn-error.log + +# eslint cache +.eslintcache +packages/hashai/.eslintcache \ No newline at end of file diff --git a/apps/sim-core/Cargo.lock b/apps/sim-core/Cargo.lock new file mode 100644 index 0000000..31075f6 --- /dev/null +++ b/apps/sim-core/Cargo.lock @@ -0,0 +1,1243 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66" +dependencies = [ + "memchr 0.1.11", +] + +[[package]] +name = "assert_approx_eq" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c07dab4369547dbe5114677b33fbbf724971019f3818172d59a97a61c774ffd" + +[[package]] +name = "async-trait" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b98e84bbb4cbcdd97da190ba0c58a1bb0de2c1fdf67d159e192ed766aeca722" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bencher" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfdb4953a096c551ce9ace855a604d702e6e62d77fac690575ae347571717f5" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bstr" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279" +dependencies = [ + "lazy_static", + "memchr 2.4.0", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" + +[[package]] +name = "cast" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cdfa5d50aad6cb4d44dcab6101a7f79925bd59d82ca42f38a9856a28865374" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi 0.3.9", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "bitflags", + "textwrap", + "unicode-width", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d976903543e0c48546a91908f21588a680a8c8f984df9a5d69feccb2b2a211" +dependencies = [ + "cfg-if 0.1.10", + "wasm-bindgen", +] + +[[package]] +name = "criterion" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab327ed7354547cc2ef43cbe20ef68b988e70b4b593cbd66a2a61733123a3d23" +dependencies = [ + "atty", + "cast", + "clap", + "criterion-plot", + "csv", + "itertools 0.10.0", + "lazy_static", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex 1.5.4", + "serde", + "serde_cbor", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022feadec601fba1649cfa83586381a4ad31c6bf3a9ab7d408118b05dd9889d" +dependencies = [ + "cast", + "itertools 0.9.0", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94af6efb46fef72616855b036a624cf27ba656ffc9be1b9a3c931cfc7749a9a9" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52fb27eab85b17fbb9f6fd667089e07d6a2eb8743d02639ee7f6a7a7729c9c94" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", + "lazy_static", + "memoffset", + "scopeguard", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4feb231f0d4d6af81aed15928e58ecf5816aa62a2393e2c82f46973e92a9a278" +dependencies = [ + "autocfg", + "cfg-if 1.0.0", + "lazy_static", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr 2.4.0", +] + +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + +[[package]] +name = "env_logger" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15abd780e45b3ea4f76b4e9a26ff4843258dd8a3eed2775a0e7368c2e7936c2f" +dependencies = [ + "log 0.3.9", + "regex 0.1.80", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "futures" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0402f765d8a89a26043b889b26ce3c4679d268fa6bb22cd7c6aad98340e179d1" + +[[package]] +name = "futures-executor" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "badaa6a909fac9e7236d0620a2f57f7664640c56575b71a7552fbd68deafab79" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" + +[[package]] +name = "futures-macro" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" +dependencies = [ + "autocfg", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a57bead0ceff0d6dde8f465ecd96c9338121bb7717d3e7b108059531870c4282" + +[[package]] +name = "futures-task" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a16bef9fc1a4dddb5bee51c989e3fbba26569cbb0e31f5b303c184e3dd33dae" + +[[package]] +name = "futures-util" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" +dependencies = [ + "autocfg", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr 2.4.0", + "pin-project-lite 0.2.6", + "pin-utils", + "proc-macro-hack", + "proc-macro-nested", + "slab", +] + +[[package]] +name = "fux_kdtree" +version = "0.2.0" +dependencies = [ + "bencher", + "quickcheck", + "rand 0.3.23", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "libc", + "wasi 0.10.2+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "half" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62aca2aba2d62b4a7f5b33f3712cb1b0692779a56fb510499d5c0aa594daeaf3" + +[[package]] +name = "hash_types" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde-aux", + "serde_json", + "uuid", +] + +[[package]] +name = "hashintel-core" +version = "0.1.0" +dependencies = [ + "assert_approx_eq", + "async-trait", + "criterion", + "futures", + "fux_kdtree", + "hash_types", + "lazy_static", + "rand 0.7.3", + "rayon", + "rayon-cond", + "serde", + "serde-aux", + "serde_json", + "tokio", +] + +[[package]] +name = "hashintel-core-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "futures", + "hashintel-core", + "js-sys", + "serde", + "serde_json", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "hermit-abi" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +dependencies = [ + "libc", +] + +[[package]] +name = "itertools" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f56a2d0bc861f9165be4eb3442afd3c236d8a98afd426f65d92324ae1091a484" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "js-sys" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bdfbace3a0e81a4253f73b49e960b053e396a11012cbd49b9b74d6a2b67062" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e" + +[[package]] +name = "log" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" +dependencies = [ + "log 0.4.14", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "memchr" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + +[[package]] +name = "memoffset" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83fb6581e8ed1f85fd45c116db8405483899489e38406156c25eb743554361d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oorandom" +version = "11.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" + +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pin-project-lite" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777" + +[[package]] +name = "pin-project-lite" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "plotters" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a3fd9ec30b9749ce28cd91f255d569591cdf937fe280c312143e3c4bad6f2a" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07fffcddc1cb3a1de753caa4e4df03b79922ba43cf882acc1bdd7e8df9f4590" + +[[package]] +name = "plotters-svg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38a02e23bd9604b842a812063aec4ef702b57989c37b655254bb61c471ad211" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + +[[package]] +name = "proc-macro-nested" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" + +[[package]] +name = "proc-macro2" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quickcheck" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b333da40686cc05db13d933f8e7b450f403cfc5a4d005154d8d4a5ba9d14605" +dependencies = [ + "env_logger", + "log 0.3.9", + "rand 0.3.23", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c" +dependencies = [ + "libc", + "rand 0.4.6", +] + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi 0.3.9", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rayon" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06aca804d41dbc8ba42dfd964f0d01334eceb64314b9ecf7c5fad5188a06d90" +dependencies = [ + "autocfg", + "crossbeam-deque", + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd1259362c9065e5ea39a789ef40b1e3fd934c94beb7b5ab3ac6629d3b5e7cb7" +dependencies = [ + "either", + "itertools 0.8.2", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78120e2c850279833f1dd3582f730c4ab53ed95aeaaaa862a2a5c71b1656d8e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-utils", + "lazy_static", + "num_cpus", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "regex" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd4ace6a8cf7860714a2c2280d6c1f7e6a413486c13298bbc86fd3da019402f" +dependencies = [ + "aho-corasick", + "memchr 0.1.11", + "regex-syntax 0.3.9", + "thread_local", + "utf8-ranges", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "regex-syntax 0.6.25", +] + +[[package]] +name = "regex-automata" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1ded71d66a4a97f5e961fd0cb25a5f366a42a41570d16a763a69c092c26ae4" +dependencies = [ + "byteorder", +] + +[[package]] +name = "regex-syntax" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9ec002c35e86791825ed294b50008eea9ddfc8def4420124fbc6b08db834957" + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-aux" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae50f53d4b01e854319c1f5b854cd59471f054ea7e554988850d3f36ca1dc852" +dependencies = [ + "chrono", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "serde_cbor" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e18acfa2f90e8b735b2836ab8d538de304cbb6729a7360729ea5a895d15a622" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" + +[[package]] +name = "syn" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thread-id" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9539db560102d1cef46b8b78ce737ff0bb64e7e18d35b2a5688f7d097d0ff03" +dependencies = [ + "kernel32-sys", + "libc", +] + +[[package]] +name = "thread_local" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8576dbbfcaef9641452d5cf0df9b0e7eeab7694956dd33bb61515fb8f18cfdd5" +dependencies = [ + "thread-id", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokio" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6703a273949a90131b290be1fe7b039d0fc884aa1935860dfcbe056f28cd8092" +dependencies = [ + "bytes", + "fnv", + "num_cpus", + "pin-project-lite 0.1.12", + "slab", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e44da00bfc73a25f814cd8d7e57a68a5c31b74b3152a0a1d1f590c97ed06265a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "utf8-ranges" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.3", + "serde", +] + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi 0.3.9", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasm-bindgen" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd" +dependencies = [ + "cfg-if 1.0.0", + "serde", + "serde_json", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33f6a0694ccfea53d94db8b2ed1c3a8a4c86dd936b13b9f0a15ec4a451b900" +dependencies = [ + "bumpalo", + "lazy_static", + "log 0.4.14", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fba7978c679d53ce2d0ac80c8c175840feb849a161664365d1287b41f2e67f1" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088169ca61430fe1e58b8096c24975251700e7b1f6fd91cc9d59b04fb9b18bd4" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2241542ff3d9f241f5e2cb6dd09b37efe786df8851c54957683a49f0987a97" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f" + +[[package]] +name = "web-sys" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e828417b379f3df7111d3a2a9e5753706cae29c41f7c4029ee9fd77f3e09e582" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/apps/sim-core/Cargo.toml b/apps/sim-core/Cargo.toml new file mode 100644 index 0000000..000849a --- /dev/null +++ b/apps/sim-core/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] + +members = ["packages/engine", "packages/engine-web", "packages/engine-types"] + +[profile.dev] +opt-level = 0 + +# Enable debug symbols in release mode +# The impact is low and we get debug symbols when Rust panics! +# See: https://rustwasm.github.io/docs/book/reference/debugging.html +[profile.release] +debug = true +lto = "fat" +codegen-units = 1 diff --git a/apps/sim-core/README.md b/apps/sim-core/README.md index 095ed44..eb3394f 100644 --- a/apps/sim-core/README.md +++ b/apps/sim-core/README.md @@ -12,4 +12,142 @@ It uses a legacy version of hEngine which is no longer maintained, separate from the primary [HASH Engine] (**hEngine**) found in this repository. -_The hCore source code will be made available here shortly._ + +## Project Status + +**hCore** is currently in the process of transitioning from being closed-source and hosted on our internal infrastructure towards being a free, open-source IDE available to self-host. Much of this code dates from 2019-2020. While we're making it available at this time so that users can continue to work with and run existing simulations, additional migration work is ongoing, and we'll be changing the way simulations are created in the future. Upcoming tasks in 'phase one' of the migration include: + +- [X] Temporary removal of legacy Git-based UI elements +- [x] Allow for "project export" functionality in the development environment +- [ ] Re-enable "new simulation" creation flow +- [ ] Introduce local file storage for working offline (outside of local storage) +- [ ] Introduce GitHub integration for simulation management and storage +- [ ] Introduce prompt to ask users to insert Mapbox keys (securely stored) where not provided as an environment variable +- [ ] Re-introduce "Example projects" accessible via the menus +- [ ] Re-enable Git-based UI elements (such as the resources and activity panes, as well as ability to fork projects) +- [ ] Re-enable executing simulations in hCloud from hCore itself (allowing access to cloud-only features such as optimization experiments) + +While we work toward completing phase one, please be mindful of the software's current limitations. + +Phase two of our migration process involves enabling users to create, work with and run [HASH Core] and [HASH Engine] simulations in the [HASH] application directly. This will involved re-enabling simulation/behavior/dataset publishing directly on HASH, and a whole new approach to using typed entities in simulations. + + +## Limitations + +In its present form, the version of hCore published here is for the most part limited to providing a run-only environment for simulations. Current recommended use is as follows: + +1. Run hCore (this `apps/sim-core` project) on `localhost` and view it in your browser +1. To open a simulation, use the 'import' functionality and target a `.zip` file containing a previously exported simulation. _This can be downloaded from a project's hIndex listing page._ +1. You can now run and edit this simulation, however file storage is simply maintained within your browser (using `localstorage`), and changes you make will only be preserved within this web browser. +1. You can use the 'recent projects' menu to switch between other projects that you have imported. +1. To experiment with an example project, import an example project .zip file from the `example_projects` folder. + +Please exercise caution if authoring work inside the self-hosted environment because any simulations you author are **not being preserved** outside of the browser environment. These limitations will lift as the project status goals above are accomplished. + +## Using hCore + +You can either [self-host](#self-hosting) hCore on-prem or in your own cloud, or simply run it [locally](#run-or-develop-locally) on your machine. + +A [hosted preview](#hosted) of hCore also exists for demonstration purposes. + +## Self-hosting + +To host hCore yourself, you need: +1. To build hCore with output suitable for serving directly from a webserver +2. A webserver + +There are countless options for this, but we use [Vercel](https://vercel.com/), for which [instructions](#deploying-to-vercel) are below. + +### Building the files + +First, the environment in which you are building the files must have the correct dependencies available. + +If doing so locally, you can follow the [installation instructions](#run-or-develop-locally) below. + +If doing so remotely: +1. Ensure Node and Yarn are available in your environment, e.g. by + - using a Docker image that already has them + - use a runtime that already has them (e.g. the Vercel Node runtime) + - installing them as part of your build script +1. Run `sh scripts/install-dependencies.sh` +1. Run `yarn ws:core build --copy-index-to-root` + +The output files will be at `packages/core/dist`. Serve the contents of this folder from your webserver. + +### Deploying to Vercel + +If you want to host hCore on Vercel, you should: +1. Create a fork of this repository. +1. Create a new project in Vercel, and select your fork. +1. Select 'Other' from framework. +1. In 'Settings' -> 'General', set the 'Root Directory' to `apps/sim-core + +Deploy (or re-deploy) the project, then visit the preview URL. Future pushes to your fork will result in a new deployment. + +## Hosted + +A demonstration deployment of hCore can be [found in our sandbox](https://core.labs.hashsandbox.com/). + +## Run or develop locally + +### Installation + +Before running this software, your environment will need to have installed modern versions of: + +[Node](https://nodejs.org/en/), [Rust](https://www.rust-lang.org/learn/get-started), and [Yarn](https://yarnpkg.com/lang/en/). + +With these in place, you must use yarn to install wasm-pack: +```sh +yarn global add wasm-pack +``` + +To verify your installation, from the `sim-core` directory run: +```sh +node -v +yarn -v +rustup default +``` +If these commands output version numbers, you're all set. +For the first build, simply run: +```sh +yarn +``` + +#### Supported Environments + +The required dependencies above are available (and consistent) across platforms. hCore can be built and run in modern Windows, macOS, and Ubuntu Linux environments, as well as within common VMs and containers. + +### Running `sim-core` + +To run hCore, after following the [installation](#installation) instructions , run: + +```sh +yarn start:core +``` + +This will compile the application and host it for you at a default location of [`localhost:8080`](http://localhost:8080). + +### Development and Troubleshooting + +If you want to run the application in development mode, which will enable hot-reloading when you make changes, run: + +```sh +yarn serve:core +``` + +See the README in [`packages/core`](https://github.com/hashintel/labs/tree/main/apps/sim-core/packages/core) for more details. + +### Repository Structure + +Several different packages in this repository are orchestrated as yarn workspaces. Important packages include: + - `core`, which is the React/Redux/TypeScript frontend of hCore + - `engine` contains the hCore simulation engine, written in Rust. This is a legacy engine that is less powerful than the newer [HASH Engine], which [can be found separately](https://github.com/hashintel/labs/tree/main/apps/sim-engine) in this repo. + - `engine-web` bundles the `engine` package into a WebAssembly-backed JavaScript interface using `wasm-bindgen`. + + Additional utility packages also exist to facilitate minor conveniences. + + While each package can be built and run separately using the `yarn` commands within its package (see the given package's package.json file for guidance), the most common commands you will run are: + - `yarn start:core`, to rebuild everything and then host the hCore application + - `yarn serve:core`, to rebuild everything and then host the hCore application, in development mode + - `yarn`, to rebuild everything. + - `yarn fmt`, to apply formatting to source code when doing development work diff --git a/apps/sim-core/example_projects/ant-foraging.zip b/apps/sim-core/example_projects/ant-foraging.zip new file mode 100644 index 0000000000000000000000000000000000000000..af562c3007ba0ce03c84decf0df51a178a3cd7ce GIT binary patch literal 17094 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 RV&!7x&+!|>PhX+A{twU$XF&h} literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/boids-3d.zip b/apps/sim-core/example_projects/boids-3d.zip new file mode 100644 index 0000000000000000000000000000000000000000..bbe0bc3a8865116b55b2da74fdc0b54f5d5f1db2 GIT binary patch literal 8883 zcmeHM%ZprD8LxSXsS%xFMp4Jdu|TNmRBpc}DjlLG-9g5XN$7+uY?{icdrwu}Z3L*-EBDm8(z~z7oT?jsK6x|MjApXAdx{vC|%xEBsx%BO-`p!Au z`QDFnZe4%s>2`~rZ_0n(efv{C`t4VqY_;e;2iWelD?2x?d2z5K;xx$%$qSLQVj@`Q zY8nQ4hmE4te-I{P#-nj6!(tM%Fk!>An6Qn2@g!iI0n0P)3$NY2ld?b*LdIbt*nA?S zU>tM9fW_%dm{u!-OS(UXoCNaK;FFebtk8b6(gJWP`+7PbAc*s?Sa3(RI+A3I}9ppAK+_fEv0 zi;P1|%^moR*z{#OSJW+$MzZlIJOKwoQOt!%*v5{Q-wqQ21f!%LgpdG{-yty*=$1?~ zA&Zdo!iNy@)kClogrh@}0?o+7cp52+Smq}88K#V&fdnGAIvk1#pM|N+)yAV#LTuP8 zXY(+MSnB&zi4E@EyEh91k+vsAk>y*ftA3DNnC9!=gy$2FhpP~oSS7XQUYxy5^KaFx zOqrYc_cT6p>95vjyqR48_xcm97QN4z8RIA&@+kN2=V@}(W~>8kC&j=|r%BP-Vi(u? z1hnb}WJ5z>YY-0y;ENjI3<;oh4Jwre*cY0xK@jEzPkccOyo+j|QW0BhQ$dO}OQUpr zh)F~qXvqba{$wCwkkcICmuVj02ZJGqhWBi-y$hRbeYUx_Z-x#}9(q_As;!wzo@G$> zAd?~&B#Vv|qe%FqyAHNov=?>pRu%98{FsGeo;Os$-->_sAB@raf}6t=9v$XkMGNV) z$fgDHVz{;n*;C9icEmbqMnvg>g#zyrSxRhKl&aNwy2b$+6=4yPygOG&%S^A;LQKvSXAVtrk&JD@ds z1*w$2LXb3({_i(My8Y`P{s2LUef zi;8e7CeCI&nvz6}n2W=v;6HSXD1!4cTqtC+lK?$=o$D)v- z|0<6ahgIEo^w7=WeKPNv8plp{Q^l;ZiK9;9mg|5|x|bASNf_&E7dF;9i;`6vHyDDq zo@3j*c z^=sFUWjf6)ZBk2~JVP^Y*@6rzc3^BuNug=b-~IY?KmO@ctroq{!agZThe=q#m{(U; z+H8e=l@eXdj$Q$DJ4JRpC>C&3^#y5MB10i}BHBJwhJhukZ4Bpy8eo@^Q}yH?yZmLv z;OZ*du?dd(9CZY;mq6-EnLufQ^t;J`cY|_YH61``8u1abnn{p-R$;Uc6goPy1vWPO zdIm?gcL^Bqhy8a&F_p-8oWTx)G-e59LGT~~HiONj(ru@UFHntkk2Uc=v+{r$E!roi{tp|scA{vM z0KfUu_kXz#_ow%hOLMD&X+#92jN1ij?gc7!d{DJarKqB}8avd??Pl<3%(h{}(n;o| z(Dtn-JMYu>kWh*rQXO0Rx`UCoYa_lhK{?LQLL{i3a=^k7>wZpQ_G~f0UVoh#kh7+q ziN>^98VQdubUQb~Jg1U75h6gaP-u{I)Ti-htw#zr3q!&c*VLGnhDWI>+igSRwk07t zE`TFKWK>ERz(2vMCLB%JI7F3(qSY8b?MIxswO%BWaWS!Mz}>fbm=&7yTdT`~wD_rO- zV%k-Gt}gQg)a!_MwGalB__Go(Sxv+oR2d;*hL)d$IEgxl<|aU!N}MQ484IwbO=M+4 z@f6vV{E;Xf3wz-{1!IJ+x>LS%|9cO2;9&HA_Ebkh9HH*#T<3u}RAm8*=yRRnG>nQt zm<#|NOmnJml4%stP;iJkDD(%&3L>W!BlJ+Bne5+a|M?HUhnLg)i6sIm!R~!We~*s7 zZI2y{pQEIqyzuT19h?`#{!|2*q4Be)6gCK0zH#N7N#(<;XByhP)~R zid|HBGna)*VjgKgvLquO*{?+XI|)gjIV~bQqKNdXx4+~41gAgretcUhT3~B)@InZcnB{(bRefksy zKO*~j<9926xrw6}dOy0fy(!t>H?E{wtiEbnVO1qM+cJIe7VJPrN7qZjw91Cnm`TI1 z-c=9VSi5YwD)b)L)0%R-9&2sDW<#E%V#g_eEgd*7aPyk!RY!UZo05>>E6TwJnG|Br zDEgMWD`SC=$oko9-?;xgYCU>iTC)CHAN$z&fVM4Hi$M7{;y9Q-$k7vzaB^gCV$g-T z@9r@Fgo7Yx{qYFpw;jn_WzBfjHIl9g!L6xG_Ha``fBWRebS8(U4Q(Xd)C_Q%5QRg} zN$-_pSKUnyHT>}J+Gj!X#93ZnZbC^17Zl(=>qzCx7J zW0(MhoxhACZ8Xv&sz-dJ-CSubD3(ex&TQS->7Mj|^yMd>e6e-%tu4r?Jnjzn*7f?b z%A6Km!|8|aB1TAx`ckgGqIoT6*R)#SGApX5vZw7ww@&(@{r-wDWbdHrJ2r2j>?)i| zgJiv?b|=-`w0SR6BG`K7m|L3W^5!i~x%>f^H}7nktD9%&**k%(?^H8G1GylkGc$Aqpku?t(N@V|9 zLT1~OGiai&qhHfgG$IhLemhf(7C_7bkL-)J&cl@gV)<@4ZUO^Qk^#}d>mNlR3J zUqWTdgVU&?nxcFn9P8BWrdnY%@igm$63<&pc*ZIxtvW$9L`Fi-s6g=o OZlM1LfB%J&@P7eN)<1dx literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/city-infection-model.zip b/apps/sim-core/example_projects/city-infection-model.zip new file mode 100644 index 0000000000000000000000000000000000000000..81ed667b2fca14de31f0ab2b3fa839b9197fc0b7 GIT binary patch literal 103249 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 literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/connection-example.zip b/apps/sim-core/example_projects/connection-example.zip new file mode 100644 index 0000000000000000000000000000000000000000..4a9d770907210db489eb62ddc79917c97911ab05 GIT binary patch literal 3888 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 literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/empty-template-project.zip b/apps/sim-core/example_projects/empty-template-project.zip new file mode 100644 index 0000000000000000000000000000000000000000..3d14c970dc6f04ba50e20ea4298f3d0337da4b34 GIT binary patch literal 11593 zcmd5?O^hT}9dAB$8#Njr9tilhk)>zp>6sBFW_D+nWg&vNKv;r-wyjimy`C<%x~i?J z-kE085f4J*#e|b4l9-T~VC0|&Pbw!5Bzlk-HIi^NF~*B$BmVyX_fb`^Kc)vTv9dMW zRrUTK@BjV&?>+VS4L8Gyk)KPE4U>aR;E%`?G0CLp`B~T(1L^0JRAwR>h(|A6ezaYGET^%PC=lZ`xf%vi z1Ys)sd6>i^i~$D`Gu^%NpKg!xKWJ!FjE<__w#z~ayPaC^}p7G|;>m`#o$nd7K z-4Z+7-In?=o%?|IGy2#V`G?+6hWo=DP&?c8S(OtIx7W@1bv2X+nZxa$Uf+J>ZhWKr zRy)Rz{b-tnC5DqpKAz;n-L+Xu5b<%8@N=i@(*4;(&;0%!;G+98xC1$saUkPx0wE66rnJR;breqR`5qieev3>ZcayG*pCS%3FpGp~*;anXs zG9z{7OG{!6<0J*qHB*zt-&wu8+~Gf^?Z{Gd9RKIPeg28RU018oedpX*$2zu7pRSA3 z;_GRMNbHMV8p?qPWY$l^F$Y#e-)S;Qg`p;fGL>zDp_?PR{d5pUk?8vr!~IyW@+qc= zF_{`(hB@LqM>Qe+{t&4^jtL7SA4d2pFswgB$fr16gh22!2xA#gkT1tufNs_MNu1>c zcYC6V1e41a5<#5H!+b;RontTC+7izt6R-!)`hA%pHBe}xTmm1?5y3=ns*?z#Xz$Cs zxdClNGB<4RQToaye=>f!aRy`XFC2)bp50kPy{-9eZgk2qy}O|uUyr|UPwWtuSsi~$ zjAo!b$EC`1iVqB67KRSCoN2V_GCNC#F}Q@~ygi;|!)7^~C6uhG zq-t<^rgBAtMbXzz|VbrXO1u%x@Hv2RGQ&HQwkbZLM_Y4hU>Cl`qiu74aoj* zQ|MUWhgG*Czc3oJ#helmqRL!gsh>E}P+!3}A`TwSB>i}bxzQVnGJY?jspsiL;@zx% zVpT|TX933TMWylbS(vhOML4>5Jp`)*#LoIi3TakD)_dRl`}^O!saB)=;s-&et;kFn zx>*<|)G(bnGjR~+L!k;BT2}SB{6-o@t`DdJpg3Z3w<*dpawPh|xZq=~avMG7N<7hl zFPw(iHVg(+)=_nbTCMuclyUsO{@QN0Rj?{jJ%P=-MF2t`<@hs+!*5NnMl5Oq4uD+P z^biz-05d~L!{rNEr*&tV!B%~}NTnjjHDOkUEn${|VumQ9E0=QZU{{qYpeO_fVF3B> zSu~v;nvp@;*~VBw$Pk)zov~*Lsj-GEkF|s6bx?_>bDvdMrSPlVw;Fu4OH=GYWpl~D zf)zj3^(4hO$pw~R=>b=t@(@9kmhcJ!<1IS<0{Y5tOD)3#^MuBVV#bOxt|!IL6AFFk{&?7Y|m}t8~obx=i`e z%$hO~{-vouUFbxZp^;gK80vyZ2gZMaAexMOquU<&R0X`NH`UJdnczx#(DzJU-=_oE+8s9%_g=X_wjq^yd4WMxX% z{~zhxU4d8S2@%?BNji5Ce4uo`2+6W^9>w3eWAgD@jqWAsd^I}twcG)tHF=*Z{M}vU zAC~s+F4nmoo1mAZo=$yJlYxEziqXs4oa^|Z_;Q4X!c zgNPP!u)F(oybl8(PkLey>)2wMYK|u8=MutQQAUo?LhJTjaY~cf5S!=bD4i`p)bgIA z*kc1R;wTwxp|U^+LB!N<=%F=+xM_r+3C(s++r1of_ASp%}LY^X20DJ@7>pEeH_P^6Z#h`X=^l6k4dj<|!6#OPtaYZHb0s7wC7&D_#XP))<>RY5}BI zROm$iuLze>R|vyecup{_Y~b_SuW6W2qGJ(2k8@(dyE0lH#}?GXCO|1S7@=lNBScxZ z=W6HNUKPKI-C8gpZE6f>paA5yGa5{-AaxcP%52XAmq?WdYEsq|V4e@bB2C$S2DXc* zSgNuWC9Gva86}xC?3v_fD4aUQyqL#p7B`L6xs1kJOhQB07X|mc&LlW?oFtB>M9Oe? z9)5&3hC2n1+9qGh+tJLdrj3Z8=d~tn$a=LP%6eT!`-w!Lt=Yel8RBQC4S8=)Mvfv_ z>qOakF?E$nNpxC@(9;oK;W&ij3q`Ymwee&*@Qq}ihJ6(!tsYy%oY4-2%z`;>2Txv6 ztr%{sSVqaWsJH87;fsjw0}57(bqe?8EVEXSGMEq|u!&=W6D<81T1@y%ifa1sm7*(IjP0)G#{^%;r*{f>XkcO|Akf z|I*e&(?H#eS#VyU7Pg0H4{5l$abEF_=T76W(G_S&SB0Y$%mb!N1~4^i5%rm~!TRjK zA9&?!*pr}pN#&~YQ?Zj*?I1bX3;TKtD_3V2N*k@~3PWTn50~;VDC#YS$4@T>!gl4OKgkH7ipUAMyQ=st5?RMoxVg?%wK1SK1~NW6j*gESd&d6?M} znR-tCfQ~*;^_XMVn0*YQ?(Dr zTWRo10A^H*qGV0b$!E@R4ukR!+8|WMswrS=jH-D+s33uc*Co|bZ#L)<210N{%Y)}h ztsiHB`imdD(>o0r)BWIwll7c?AJyo+?)uwm%g>>JnbKuXr9Aa`6|6L-cB6fu-Tq(V zR(4v2a1Vx9pxR$FM0wkC6+Jr4p)R7=gSncc9aW0%;SK{;tG#StDn~tBqV7qK0>NK_ zpzP?Z1Q*;r2T_2(i(M-BRD=udo{A`-Z@IxaBjJJ;Lu)nK2v-*g>oR)ZZVadj&D|I` z(6!pP=U|;}a+eKv3$;M?+|RVl+}UVi0Z7$ptL=zE&_eh z1vQ(c%MqM3@O--ErNs!;E`T}Nvx=X4D(K!?2i+q7vRLqgwo}zbQ@kGM%PP`Ybo^ws zRk+)$20i3=eB??r#b&E39Cr_`Aok~5jy)5X*pZe+X;@t(yVpLkilXlS(Ps2=bD@Tr zx(MNW>mfW!!?Yi!E<$=YP6sU4u;p^Kj@|t+L!eemKL~m)ctad=cZKA3X$yi~_~^0c zGf(iCx{hvJNY$;k-DwnT*J@Q=zqua5m#-8%Q^dWo2SJbF5JGB5UQr+l>bQzwi61 zs;g^yJmb5Kpry8_tLyvftM6ZZ-&fU-KKsH)RvL8uVfLS2`1ikg`K6zGq0ylG3fdlj z=D}MJKhqu!-Wx`_8l{6UR{b<7f+)#Vahj@PIu3J{9;)CdOp3g#@?kp8RgetS!DJd{ zd0V|xs36W$wHKTOQ5+n^p^6gqXj%-@WWO~migDiUbcR7bYzI*%N(SN6&W-SPe{gg2 z=B@B{5NvMWI@sF2v3>hy&|wlbMnQHQ7HjR5l_xyhFgOWSG0ehHjnh1jXpFoFim7?sOUzL$AvbMo8A$N=6L^ zU!3Fx#toxPjkBnai5=2o7Aj0f9ia2fFod3IQl~?_7T|sgJYX>JgXj#cfqBJvG0{&m zH`OWllb$k{RRG4SewdLoR5BSI01}cA2^@rnDd+(HDM_NO-c3LsKlXtfP3ka9N5DY+ z72`{D>(C6Ase}Ch1T*j*=rxfkX{S_xo`> zNwZ>j8s?a6(hdicP7)SzdX$QM^G-h24TunkplJy$;Kq#Y8yq& zp)WFePlj)g#@7k#QN?1|bo8H(;D4U=JJlZF{_){&b)IW9=>7%W<0wuKU=-~y=4o=a zqEu58*&|`}#$iT^iMchqYO}qyPCeM1dVTP=KQ& zdtbl#+@Hgt(fvu=CrE;Lnn#Wd(n&F%6f~5kS$qdk%%m!H)=bBEY#v52G`G1m-10p-1*Y&2yQ0iBF_@&Z8K2mHAE6Ny zzT#P{nif6=h=VEg5rRK!o<@UWNZPZtx%nDR0t?R3klr<~qrtMKiM0=|jur2$`IO#E ze=CdG9U#mAx;HBuMr6PQar2x8w$jVLx zx&d_~udg19*Ef>4hI zgXh-UPTwd2cZpctUnC|Sns#OBY1)jJWjYLX&~OLg{MlH#E!yf_g=ks9sw7uOa7o_U ztP*V2y=P3YL0d;~4U1(#*K=^my&-K%RjJl%^D`#etoV*_SYsgREmvY%FRr<5F45Pj z#Oko&853)Qks4xMx-_j7`fF(QZS?pPfAZDuz&7drIBp8~D0*W9-8&<3G)*|l?w_aJ z)bT1uab(O|{HC3QcBgqMZ>3F3yQ%s9hAGhgxhbv>f;%H;ij{7DK=t!_dUiTqVD;jI zH2lYpHX3yQ(oA2TDyG!AdUZuzRh&T@OsXGDawJ_8pEE%exW{RFtb&ZQ2MXSqCj?44 z(;*Tc?w%$gatowp1S_k!&O3mDK#&);0o7SqIh-VYS`T$DLQ8e4Kglw0D{6@p9};SPoV$n+|>+8h9?S9W$xl&4l#)hn=)B5Wh$>VX|=Ij@r7zBBL>+uN$qX9gMMQa4DHXyV^s!(`<7@FDTDd80Vp;gJ=CDq%<-K zPpizt*in&lTgJpJJ&?LvLy|^8KI?uBan5U$5SVA~gf#8wg#^HnE3?ezGQz8MEp6K@ zDUJ~`r@^DCpPniT*;F<`Sp#90dIt)M3_P2fN(P25r7=%(6~R;_uXccJ63RR-7XOas z#lxT&whv(wS&M&4*wI>pJ^17E>ba)g5WT6g$XcA?4wY}hrv>Q`MiN@a6Xg8?)k${L zA@WiXigz%HUpH3Ah6PIk=i;; z+9p=r+uUb`)`aK?mReyxPPeVvZd?06w}#$Ku<&KBnG^TxHO7#v({d18&tk%$Ir9!z zdc@(Na>ErP@>8NYF!psdfImtBL5hQa7~Jtawf=&8RTXD-brw|0EO&QmECrp7XP6bO zuY%ewl|m;X1N7x@9*FVC>t4>Pd%A|jbteYtps zGA$Kt)gZ*;%86rci)K1b;dRt5qZj!S;|$JNT^U@8RT89_f{;A*T0N9P)>SlEgU?hgVXLC@HcJWx zA$8#jfsz4=*%x#XZmij~o#PjzjwqwXR4ZY!FrZl)y0S8uc5QxMt1nn>6J3o=T||%% zTdrzmOxI?SlA30jNmpGr4MtC~9ie&8k+oVrp{Hd9+PJoM$2Y$lfBV(n8^G-7es!Vw z)q1`%0QQ{Hn#_fY0S@$2v~!Adv9r?G|yo$We@M(tewn3NO)$2J47b zP*Pr(;&uZV)GEk)TU(NPM*r33oGC_@RnMGC~T7LDD^)q~5pbA$+YVavQiTEGAiE z_O_Te^E5#~ND$|Dsu{fNp_+?~|NqEA{rKy@(fHcO8hroa42@dVY8?|cv<9?Y%go`h zo0eUQy!D?gsN zG>wFU3jBa%s#@P!GT@oV7kD;K`|KE1#w=lhW((D>9b}2N(X1e1CT9AJT!RJI^-dq9 zaTQe|QCN~aBI)L~BqZ^(S6~9yY@P_EgyTHLF#$MF96&(i2Z1zW zrJzF?)I~GXWquqLDDnN}o2$QdBKWFO;cWKKR;@@oFx&g_f7R4~6 z)xP3jS76Hs&R{i4W+kgV6_7C7%Bq}MUUZR*mN;9QFs@ij1Rh<#Z0nYmEJ3xVYP57$ z^6hwPPJyt~(9APBVKq@S+)U>p%aa+2vlg4xtKsf2>>rc;BbGDSzIHKmT0_fAje`Qm zEcnoh4X@=i2Gul)NYJR4GhH$;BDQR(sjje?S_uUS{8PF0lMBi$vz_M^TTqiJJDbD+ zEo<#KOpc17VLUl4?V-6;TYuIDfTUD2f-tM15d~Bkmg5?X;TjV|I6O!EQU8mK>c*^9v5m8j6wHXB>^er4x}@jUF#5a) z`EJo>^4x}|yRsRn5-z$^6tk`kD$>iOWWEdxG#EXk#y zvn&eZvS*Ii^tLBIL=BMc`}mnWgkpc zN>0Nb4$EmYfN)g&pO9o+o;ZK$M+0O1bGr$pj6o{jiUU7EY-#Kfq`_!B2@a;@ zz?-+nlYD5)O4ze3QrJ_I4oTpM33~@}6Sn^9ipUm+@O}^!0RWj)4RtPMtaChK#jh%^ zo1DD$;T5IUuLvuxcd(}jo?U&#wVhY%XCdKKd1|D}1{cBW%OSYfg144OVieT3;W+u7 zmnJ{kXwbd;LD0#R-GecxVk4PFNwnKNW96`8ce^-{g`GLP>!lfvYauYsimB8caDp?Q z3<&4}jJ|k6z1G#PM2G9@Ni>?E&WbIl$6-E+3+xAARid$of^PS5a)dx+JULK@NcK2A zvP88;$kTY(u5yv<=(4L;^)PE{FmI|k&j?ERd0P^J3ACt z5u%_&2Z9vNZ$>dT88h1pF&!G|{4WJdz=V_0n2em4gwMi-Q4r?P);C5V*C(D0AUIP= z<$FRO6|c)i)CL`Y8VDq6xpp8a$UCvLrIX?}Y%L-iiQc>}fdThL?JXV60o1vz%J^_z|a<+6I3fd4EW?g z%Mc-~V-`S;Gi*Vdia-EG%%19Z+~M7-fz9YWpPsp1!DkP;Y)m<7)3NRYfw zM}12+UN97P5G!PRLt#eFQ11NnP*xS7o}Y$B8cn|g-NjRa8VT1}kkgp?ic%7N&}ZVK z83DV!M2mS8cR~H=(seQFZ!yvSFhSUcl6o8w5I8oRG(iFgP($5)*b!O`bpk(y^~|Mh zF)uiPhB}ng(_%Z2U^9gy$ZX;nv!Hp&QS8 zIIg^}-k>PgWe&Jb*tx@WP6zH`H3G+js*%>B8FaI2rktZFPe^p1L3hf~>ZkpX+yVhkolI>Pea4`M2`+H;+-OE}A%LQ(!K>wv#LQ`lml^#^%0 zg|RXPC5sRsICyLagTX^dQ(MiqA3S**E*Kw7&{7~r^_%buUS9c^U;EL)Rq&YZPklIE z&zLuzH@fI_&U4Sd*qHwbC5Uk@`$6TS&sM>lJ`MVgOfLEe;Mxmv$k>PQzbi8^jmCQ# z!fa>IS7ttR*}Ku852xg!@%{qCo6o4KH2NP>8K_3%Uo1@Jvnqd3|4WS$!FQ2#x`xg| z@WK7hBTDe!#>cAu#}YoYe{j2meihX{GGu#<+XpSXHX3v&S}tPN^LX@qK$U6!A!!5M zXtZZwxyp~J`{Vn^>`EM)FZ$yf9JL(u5090I{tVl&)rshekL*0u5apI~$kmot^OqOl zswK`pwNfJd!90Y#p|gNC+5<1If53T|+IbpOwWRs`)g`_m&frz?5uzHB{5pAwVtaKC zids@iIQjlSo=F!!j!KeiLs*Wgs_24Jm0B(Wz2$?Nn)Q4HuNZj#QYorO;McI{qoXRe zTtv?|mO)2szi5h7)s~A;{>QQ?&00wHu`0da^QzniJ^0u1STFpmPQR)qZb6lfVfll( z=0%m#@(QrXRpfm6!4_&(Y0$*G*WaieQG!&r##!Z8NitB_2SPYp4&fpRQ`J*0Li(-c zkO{@{ z6bv~1uPV;>7b9CFV+z9MBI=&MxsbXlKYUvKpxhvDG#*|Sbu~oy2l6A*a-hGyb;&tY W2h#HVC`WD0Mze8(;Pb(4!vDVxPnW5w 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 literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/rainfall.zip b/apps/sim-core/example_projects/rainfall.zip new file mode 100644 index 0000000000000000000000000000000000000000..ba000fc1d1204eb7e99aaf49fe61fc732abcaeda GIT binary patch literal 18762 zcmcIrTZklCTF#vrSG#oA+qk-3Pv0Jt*)!ccn0;}2d*{;3t?gZx zTU;_Lv%3w5uIz|&{@eNQ=fvGtpL}YqLD${%KR@%aue|g7FF(;}(0vZi?!9>H)33i6 zj0g9JQ6|R8K#s&fj*~dc(=eBr$cIu)GbxgN{7j`3qi8P;)0xUV9LEDtt z>3^QW-z@EK6-WH-f8TxLxA2DU@6jU;M#)|{%7VAEBtBUaqDjK*kvcZJ;%b|ojKkwz zUyepSUJN{cuA^R9W$TS(e2@?E<}y#olSwj44rX{lKmp{qw+Ag4WO%aEX^ShJT>^m@ zvw1IA{vc%#O=;R?j6YpFEwThI?Db*%I*jB|R$}}6e)^C4Lzp1l-*3l+aX6Y~k;i#5 z%_q~GP-&X`q_;${qEzU|it1#7=gs|S1eI(uC+hirBu4{!)d&BDH~nNh3DXE-rq}Xk z{aH#)=^A#Q}uqAq@h+}GD;NDOeK>Jx#khHonTtM=II7128?AU z;Y!q+fM8-)uL|am!Wjet06>2f4bqW5Ulf zNEPTOmXHQ&=Bm`*R=(nu2p;Jm4aZhW|1b2Eh|gyeB{6vCGWJ23hv2|yg0BmZmEw~H zs27h^mG~5b7K;D3TUOqaFWou+J`|Gf@5Mck6B!R=+{cZ zf{v0+lV4j?+_Jys4JGHb8O5768f;bK!@Q{+cs4okY$~nYstHDg>1C$hzxB-Fm%%LE zq$1WGt2XYF|2K9X{>Nv;V0C|6`Og*00)lfs`_4Z$00ea?v)b;nyh9;^6l`?>a& z_EoFwc&S1YM0>SE%ca+>H2%eeLNMKBO(t(&xH%M3`%2YUKxFLpFJ_yv(9;DHr<%Dm9$pYTm z61N5em2dP%$#eiP3?i6Jv!Ri*den=^JyAu z{MXh_*X9Sxk80hum`efkFW!IU{2wBPp!+N*rlKohAvKJ&XQv&i+ ztD|9r%!&-=C_-k5?DvpT;p8YD(*NP0Qk7eoe2)_-7znLo7AUjXpT;VewYg|3%i${8 z#uhe|`Cx@!#=^2)h(iS6a1RNZH4fjftkY=gEEX23Gn+GwOhrgon9-ELFIZ*) zbV=O2;f86hS%gA4t`^w`V9R50*}0~FURMZJx~Lyrx@2YvB=T60ARG){mstiJttN#s z(rQ+W9-blRXnTp3$shbsE5rg9q6DuU2YK#=gIUlU;GHio0H}NoE=OfxE@))Ka3bk( za_j|~nwW<%Iw|rdBkdpcNaS!drN;$lMAX#4>`f`nToPatsNCXsu*e-<2BiCgiB^GoOO|JCo|*x3&q2=l2`oJ-#_zf19&*Pe`2Ah1L0I4oohajGcFO%oohk{ zaN2l^-zF>JG8D=}598@*L=Fd|Xi@O&bnVGQ3)JsQ&tX@!f);uA1jJ$UON@wIs%+ekWB`SNA zv>_Zh73ip;LorJF;Yf@TBgfP6@*WwB0X_P(gjd2`6w%PkB5}^=4`dpnH85-|#Z;XH zFO={sz~d}O*ZFs;nD-JQTer8^>H{v>T3V}HCkQEAv9z7SLTiwI=^?_72=Zh*Pazsg zqi)>L?#;~i->$FQ$tEghNGJNE=|EHAU7YnxB$zI6RaNkCn=dO*ip2y6r-$%{zC5^nI;e-Q)5vC z5^;IfLMLKO*(v`8){eF@qRInc2r+R0{jO+#)j*QX*{NW9M+Ii0!@O0~@rEq; z8o$sebS;bj)%cdbd+O;AeitT7_m3|!-CEb;Xto~4b0#{Gt?@hLV#xQfn3tTpM+tHm zH&^#hAN|$N{rwL$8g!qpFj-R{)cYw_N9YD92bTR59Z8+t!x=?zWX9qzl5656!y9-d zV^6E)h1=xoZ2f>fULT%O^kteOhvGyD#-cqQme}bNC{zxhQgsCuTq%QMOY%w>Yh9jI zE6v|lq96{9BQ(mXbdCX;M;Ym>?@pBNR+?llhR@G_8;e8t`Z9}DtC0^A?35;YY%i&g zI)<*Uj$i+?e}3;xR4sJ>P=!;YCv&1@ICvY4@It^;F_JoY+>*B)Xo{5-A62xW7*=dP zQ%oi}=rV`sbh{ojTGeP5tfr^8!%?4G@t-iwZHBM<-cV5!fuJDl#Z9tB>S1)$3xtIj z=tV#ddkexE1wDhz3wOY4%i(QQ2_%m}lugh^JnQhe;Ncuxxq|`i71F% z)DIxzg$z8sr$?!rq-eOJ5o2~yEEv#b03!rI2}Ze-dmJY7 zVS;TXi?2>Z%Ha~(dgn*ArxaO5j>qAom6B`}(T;$+GLM6FO_@iTGj^~dYRaTkeyaay zi0H=N5AX+VO!llPzK)Kqw50jLR{l_kk2NK%nJvFK>#D`26Rsa#KUdtQu<^ zgTA3FK&l!}g+#kZ7pJ8`sxzeqRf~#`ymssK>u^4F|JVX2RO@BE^|T z(eK}Uz&)ysN=NhezV-*5DP&D|XEmAES{^927We4IJQ-gzlOt@ntJd?*E(-s0bq}iT zMV_KY^K3?0(l|R0KPd5DICZhkUs`7RJk5XU-#)(kRUCPs`}x%XS=NeHzzSvYb-QTm z_mrCSlC(ELYK0Vi#)`G7#k9#=8>6xK=b6qUS&oGc1C;yd;eGN7^t+aq*1BS^E!H(g zbi0bvQ7LzVtENPT!Sid`L(?yhCL?JY8ba_s2%ie+iqFXYbzr{f?2zBX8{p81ds<=D zy(&_o7~z-7j~E1=AD~XLO*g@_h8@^v0a^#C-lJmIK~sFfP1_ymjM~;=b|~$sOe43) zJ9|KNDEsGk{3$4itbd{tDXm`A=HB%N{x~uNi40zn8!ossq7He4>U(Rry8-n$aNcEB z8L3ATGjLmqDELAgJV^(#N1m^SpN zoxOm1qDT2=OHmOuTsc&vmYV`vlC65Mt5P$E+#y!!8owZ{JY6rQF#!#ebs$i?=D>|I z9z=MnMe}KG^CS;PEHZSGt-9&8#y0r1f{tipXRLdXhWTqxDsxPV(h1PN!{!I8pr`Cc z3O<=T$GZZ#J4^W(q?keUcdoFA+3@f{vuc#tn{A2lag|7M09aR!#a3|@yrnBG7P|4+ zN;0wr{&kj;=}j^dW?9H9AO)wUiC&62N+C9nB4xfMf*l+t*bOHsZqh!5M-`sUXMhA4 z%cCU(2(mo9b!1uX1$V7h(FLLsb+d^gBsx_xtw#Hc@5rk+Wp7E`P^CWl4bkPOVNa*c zf_u|9#SN=&I&G_WKRWTM0Bi0Bb9@ne8Y5H*9{+Slu~0=i!+T643Y=PGFnYKq!;S0PIKd@ zJikPI5;Vrik-(UMhXK}L*TGD&ON#N#su#e0c*q3!zkW*KghL61$pr{DT?KXkpA%_| z5k~AA4p+8y@~e+*CGi#f@SN~gAjqmzog^)h)UT?@#gpmd7fa>~#kVu7xRtWwjbHiq z3*SJZOZWAMPk3ufkm3?AU5%*S0BV-1`riRz9WuL>Zi^D#u!q^7T)Cu~Q%qO4aOYS5 z8}$;df>>PQJhuuMTY8jAu7CR9Uw-dB6!~<&{BQt??!xYRvGiHBoPm!)%Mqm?Lcj&P zPj>-k6jCajQ^I@P7PAc+M!31TjY=0?NcdaYRng697@j^H9+>*5LM#^<6lRrC7Wxdz zc&fdI_S&-D20u{+EtEKOS%grnDf3bXa!9QKcpJ=UBqDSg9Je;;G+GEg?`Ze+T+uc zipQ9Dj-X`me{1wdeJcsmV4Q3>bsEW~1;L{N&)~L#PV8Mi38fA=gryjTkM{P%7(T6$6a*GKY zQ{WOdxV`(>521!94xm1utsu%fafDX1(85Q#4@aXiLWKguU!o(TmGSVEE&jHnFrFCT zGg@Gv&?K#qkv`1@QGp5SyBCtwLVv)kX`a&V06#TpG?C!Wel0_W8~sS6cxP!1 zExrra>8z41d1%%heySu;ZaEMmwnKyoOLmPY8tD+$qh&32jJzbA;B$q@U-Dq|1f#WG zb-Tzy3%w;+2Rb@s7?*OeY;OI)T<&H)!THpe`D^)m)096hGaxU>YB#*cwDJ?096Z zT`)C#d1bD7Q~(dE(Bo|xn%a(V)~6!G+K!4^zPSJ>`SCH9WUq8(Mp=UTm<6IQqz;9& zK?_>D0q6M)V^{=>iEGcB1%<87pqWLLE&}kB+9097L^M9s;*CsYZk75$8!EYcoLH;?fh>)GUfb zTrWthD$^C^`8Z_xSmgM~GIa9uj1F;*KS$$F7hwz;zD^MMiZe-VtcBvXe6isayVr}~ z2*SM#)_$FA1IY^9dAIxG-R(QCz4=DTn&$CPv+7LknHl!Gp9o&Qq_yQsh7UdQbYuQE z&sZJrvR^>oeYH5v8`HqqEq&4VVKF|o;(DMsqx>S3iX%EjcHKeCGG*nnQ4Y!nII}YkN-aTdYNJ61koY3K{Kp?H zS`+$sMYS}8=`S5f;)|w!2aaL*hPY*pC(Q>y2bB0C06$p|04=wLRM3GXz6eM>9|(zP z5j)g#<%Np)c{*j z)T~;0)59V88!%I|Y8F^&?d)?PIdcVzy8K`5T1?fgfU<-fv5f}pC-6n0e|~dGkY}wZ z?Jw{}V1I5Eu=8!Oyx(9@G#Y>Pa0n_5{hF07H?JMi54KhimgA}p>6t4}4QIYco`2e@ z#mvcc@${95}MRb1md(OG%`_}GLr*3I^banH;eeLxhzWl9MZt^_3S1|VUh4W8dz7R|X&yM4Q74f7> z!ZOZMHpvD&DOfq?teECJ9I$N2#)Vi2qw;1 znO8+A-w*kK=V8LasvKuI<^`?R`4Lacf=$DsV6+@Bif{ykWyW$K$tKB+MdPgCDNA`g z8uzojVEwS*AS6{wL{**xiy=!aE|yLB9;jbu5hyR?bj12e7QKj-h77CF!uxSKCVt~2 z%$b}Hs-6YApnfziX(|t+v7FCx4k3UTKuY$sD2gb{xy^T!C0RZ{Uo1gLja;-;Bp%be^Lz1tXRUErPKz^}o6#WMsEVy%92Vmsj5mw8CQU+ zl(MOR7a%*~y&|jfh+`PcaWSzNXZt-fyB-!3dsB|-X^E`>Oefe9AZ0d%=teUP(b8D0 z;9(w(dwDpBs{+GY7(NhlM_D%LO^dKM%=t?KN9VM7(GT76t=2)4Z?I~v58!Dp=KF;s zo44P5?w4Q2OSqH4UAq9j4<_9FqKU)?hoUnYZM}nmO~X9KrqF~@X~XS>NhQU&qmT&^5!UO>m#altm6kp`5EHIS#6F2i zuk}7r3Lf7RQiaVx)oxIt!X#}%n(|DcLYA<#K>eH#{5g@<8@6;KZ*bWR{tt#RxAKvj zSyaU|j^a$%$&F~;4H7%bIZx+legCD@C#^r3jKaOqB8As-9dXzdS*Bx$^X4?+Vd^Vm z0tuLazy37IgeQ>Fm;M05Wn3m?pZ-M|JdLX14O~CZV^03#ir;V@AjGy$WwSt+gfloD z0KmAlbN077o%@M)<8b{Q3>d=wX@n*PtNgX@8cY>Ynl&74oNJ-$)!WT>8d^o(h=r`y3+(DB{S_k%CJ{ku1Tmu?FA z{2C%Lv=C{HK@S~kMAsf-axcyDNtlrOD3imiqU2K%l{+FgP+_c`O@+2kvb5wIQJ5#0 z(Hj66>uRiF&nUvlG!Z5t(gKBc5Eo@0_ba8vK8V^-L|LwTQh~&ct>8gxtcXXG5W}57 z#>WF!DBm8v_Ug|e47xvrn=+*s*{qPr6=K-$^KrNrBLfAVE51l7c~2&iByVW((mlD# zTClDK*hbjJJf%ILgoe-w6|`;kN@tyQsX%gzk?9vDjf4e?gHaxf+Chm60Gq90m7;z? z9&(h6*#YZnpg<+B?bWm7k0o>n+Au|6 z0IAYJiuaqFnsAp!SsRQw60{7(0%9u47|EB&4GLgSc&RIiE<2y+;jB$dtp-T!lJ=^w zl3)@}+wC4;)>%AQW!-bEt?fs9WatybbH8Tsa#)UoVUlHe8^fD&c-8nM5u{<=`(u;= z#%Lr{XBu*OjCGhvt;vSKZcb%Gfvh~t2Y9D#1aB@W_Eb8^iYSB$vSFT0U^k~9-l4Lr z6Fju7Q%p7S3=;&)f~~y@0e6D!9rl0(TCf0=y(glDv&6Ps;EO_TjG1YR7I;YD-`3c* z3Ke9U3ETuvl}*aJwHK#D@?NBG>IIz16meA8npp8*RqBk=QOv)9(yu)TODNAdsm(z+ z7+jWBYTG9dOlEIe1b!0$p>isYpMK}{{u-=_?)#Qn709CEYsG(&&&c6ihu>K7`xPn% z@Y2I*uPVsLrd5)-OFi-1uix`$)b@0LYHq2TahNV6+ZAQM79lIRtZ!jFRK!v}EtN|Y zuBeTLfC$d6#2fW@?aoxCt844y+C4S|8Yu}ds|fdx6a+8hA(P%BFj~SM6?!MV220a0u ziR};#0~GxOPd9rW-JhA`vDQo#XjS-f zO&}dm*z%`UKZzrlM81bk7iXZP zp`jE-VF4!us2L$ZHwi1nDH=#QkEm5<{1k(Or`A(cRx}&kq(LRRZDl}Kd(r^f$hFpC zyLFG`hyV%&d zfs8Jx*OvE910n8SJb(uVZT|XoJr}u}GLl|27G*RA!Oo6;e;^|>Tr~WFva+whsUy_! zmnv*+f*vMq3<>E34B8@nANaG=?m7N8h(RT*CVxkSq0cQhk2z0pk~s9;hqB?{Z}mL7 z@0wf7gz1GQ2$j$FVS{KblUb3a!KR@9(pn~H1d;t%Y9}I9ICz0N&PqOFs5W^OjKT=k zHlU^@deA2|B-5j=Q%*gka!xXtEGJV&hY72gwl=m$=A%Tk%)pS9YG_Itk!-dnr1g`4 zyeR8-5xOiEeW9}wiH?THN2DQaEFup(J_<42hNt$c$R&@%37M(Pu_$xad(?G%ldO;*UJ? zh;a`DQ6p}^)WdU7P=j4ZVH?`2_nk;G`e1xtPFf%n1WnfiU6u!wTn1`Hc{f3)6sz80 zxh0E`)dbh*qz*_|X(6*zr|bHc^o}=s>Yd@~F+WNnjL4aIdOZvYX@J$vKg#7ZLSTlxGDU zo3KfEO*kny3ZT#ncFof{w#6R9eMXPadh^qe`(heZd+HrcYvh8kBJ&FGYI?fXbB)Hl zCq(84kkrpLX=bcm)ePAn$VbmQeTlo+zn;?>={<*sh`BKIt`bU-jv5M2p3a^uA${e| z$KLqwDbJ((zBv{b)~5?2Vicm{lZUY;B&t(cD@0IG4EyK~>+N0~11U*GnP<|jlLtZr ziXC2%#R(rxp3}?=5}uCAvCfQO_)m;8ob9Tt($FG8J)HL<#ncJo~okamz7zdTgzrj7|AyEYP$@ z5k<&kgAN%trEgo3z`0CUHdYL+cHV1IgvP^lvHYU!w@Hj_njFZGAsD=*PQ)3G{}Cu> zO#9nmg|?MaGQ?4|yKrdE(QzPA((XEuSrp-?^%iRvWTov#Fr(MBH~}wqaDt_hptcpC z!~xN9ApJEIYK{*94L^H06Nk0~L|nNsPQ@2~dxu_3s zAAUG(YBLWHbUfdfDqHc*gB2TqIs*#;?DLevfys+60iOTT!d`vjcnEY$&iydq_rLn_ zy?3IwNB7y2qLM7?W1@wTMoxhPMud=|MkFn0a#$Y+i9j<%#ez?9(CNSd5?mago+OaK zV!xV|@Gm@yhciltbh(GJo<0=HsAG#ZG<^nSq95#pl14fcLvo+t_&}tHWtijeR`)0+ z3)DP8vjq@yUe&*N{+WxgQFI3B2u&X3P<3)dz5MvQll7ckE~}+)y7}W?3u`X^YD>H@ zf6O;ryH7R2%nUrxH$d{DgTKE*eWac?A>40*G>qrnhMpQh*k1@8wH|$0BQIL-6cQl) zolSE^_e&cM<#}H)FpckS+(q3p(;C6|@qwFdt6~#eaQ8^G27d?O?g6O_?VcUh(0AVE zoFuxS^;FNJV>x+|u>Pq)Z6DASntM#Aq0#od-96y5k0|Jr*QLWfoTzbCA6bTLp+wyy zh#KJ^=MWWBE5u#+zrDHI{`0xozPZ)gM0QqC#oVJdJhfvc*MBwEFtb zCzc{xNS@m&t1*s0>0&gEGnXv4n5vPyjM}OxE`wwtId08TV@h#u(8RQ`f;oybN@MEl zC!akQ!i8kkSmdd_{7vTbJ-=!8$Q_#2V}nxmATI)a%>~uD#Nh}in~)bFcxy2N8EE9U z8b?t>*@e6a*@O6L`>)OUj|^x#jzUP;hP()2bv%TJ`yDs?(8xXS*C$1-Vz2sS#pS`a lvd8OH@U(vav4m3d&|FCA(ybyq-R<4&^*-o%KLbwB`yVi$-YEb8 literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/sugarscape.zip b/apps/sim-core/example_projects/sugarscape.zip new file mode 100644 index 0000000000000000000000000000000000000000..54697f78b21de8bec30d58afc1b026485335d6bd GIT binary patch literal 18446 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 literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/virus-mutation-and-drug-resistance.zip b/apps/sim-core/example_projects/virus-mutation-and-drug-resistance.zip new file mode 100644 index 0000000000000000000000000000000000000000..d64ec151387c393490a4af75904a8d602654e1c1 GIT binary patch literal 16140 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 literal 0 HcmV?d00001 diff --git a/apps/sim-core/example_projects/wildfires-regrowth.zip b/apps/sim-core/example_projects/wildfires-regrowth.zip new file mode 100644 index 0000000000000000000000000000000000000000..2c2518bdf850387b44329fd5cf61c579564031fc GIT binary patch literal 11151 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 N@11)Zjh~D7{{^-Pt118h literal 0 HcmV?d00001 diff --git a/apps/sim-core/package.json b/apps/sim-core/package.json new file mode 100644 index 0000000..9840a3a --- /dev/null +++ b/apps/sim-core/package.json @@ -0,0 +1,149 @@ +{ + "private": true, + "description": "HASH Core", + "repository": "https://github.com/hashintel/labs", + "license": "AGPL-3.0-only", + "scripts": { + "// 01": "Global scripts: these belong to the workspace itself", + "// 02": "", + "preinstall": "node scripts/preinstall.js", + "postinstall": "yarn build:utils && yarn build:engine-web", + "all": "npx npm-run-all", + "fmt:scripts": "prettier \"scripts/**/*.{ts,tsx,js,json}\" --write", + "fmt-check:scripts": "prettier \"scripts/**/*.{ts,tsx,js,json}\" --check", + "fmt": "yarn all fmt:*", + "fmt-check": "yarn all fmt-check:*", + "test": "yarn all test:*", + "clippy": "touch packages/engine/src/lib.rs && cargo clippy --all", + "// 03": "", + "// 04": "engine scripts: these belong to @hashintel/engine-web", + "// 05": "", + "ws:engine-web": "yarn workspace @hashintel/engine-web", + "build:engine-web": "yarn ws:engine-web build", + "clean:engine-web": "yarn ws:engine-web clean", + "fmt:rustfmt": "cargo fmt -v --all", + "fmt:engine-web": "yarn ws:engine-web fmt", + "fmt-check:engine-web": "yarn ws:engine-web fmt-check", + "test:engine-web": "yarn ws:engine-web test", + "test:rust-engine": "cargo test --verbose", + "// 06": "", + "// 07": "Core scripts: these belong to @hashintel/core", + "// 08": "", + "ws:core": "yarn workspace @hashintel/core", + "prebuild:core": "yarn build:engine-web", + "build-dev:core": "yarn ws:core build-dev", + "build:core": "yarn ws:core build", + "clean:core": "yarn ws:core clean", + "predeploy:core": "yarn build:engine-web", + "deploy:core": "yarn ws:core deploy", + "fmt:core": "yarn ws:core fmt", + "fmt-check:core": "yarn ws:core fmt-check", + "preserve:core": "yarn build:engine-web", + "serve:core": "yarn ws:core serve", + "start:core": "yarn ws:core start", + "test:core": "yarn ws:core test", + "g": "yarn ws:core g", + "// 09": "", + "// 10": "Cypress integration tests", + "// 11": "", + "ws:integration": "yarn workspace @hashintel/integration_tests", + "integration:open": "yarn ws:integration cypress open", + "integration:run": "yarn ws:integration cypress run", + "// 12": "", + "// 13": "For common utility functions which must be in sync between packages", + "// 14": "", + "ws:utils": "yarn workspace @hashintel/utils", + "build:utils": "yarn ws:utils build", + "fmt:utils": "yarn ws:utils fmt", + "fmt-check:utils": "yarn ws:utils fmt-check" + }, + "workspaces": [ + "packages/core", + "packages/engine-web", + "scripts/integration_tests", + "packages/utils" + ], + "devDependencies": { + "@babel/core": "7.12.3", + "@babel/plugin-proposal-class-properties": "7.12.1", + "@babel/plugin-proposal-numeric-separator": "7.12.7", + "@babel/preset-env": "7.12.11", + "@babel/preset-react": "7.14.5", + "@babel/preset-typescript": "7.12.1", + "@types/classnames": "2.2.11", + "@types/dom-mediacapture-record": "1.0.7", + "@types/file-saver": "2.0.2", + "@types/hookrouter": "2.2.3", + "@types/jest": "26.0.20", + "@types/json-schema": "7.0.7", + "@types/jszip": "3.4.1", + "@types/lodash": "4.14.165", + "@types/mapbox-gl": "1.13.0", + "@types/node": "14.14.7", + "@types/prettier": "2.2.2", + "@types/react": "16.9.56", + "@types/react-dom": "16.9.9", + "@types/react-plotly.js": "2.2.4", + "@types/react-redux": "7.1.16", + "@types/react-select": "3.0.26", + "@types/react-splitter-layout": "3.0.1", + "@types/react-tabs": "2.3.2", + "@types/react-timeago": "4.1.1", + "@types/react-transition-group": "4.4.0", + "@types/react-window": "1.8.2", + "@types/request-promise-native": "1.0.17", + "@types/shelljs": "0.8.8", + "@types/stats": "0.16.30", + "@types/url-join": "4.0.0", + "@types/uuid": "8.3.0", + "@typescript-eslint/eslint-plugin": "4.8.1", + "@typescript-eslint/parser": "4.15.2", + "babel-jest": "26.6.3", + "babel-loader": "8.2.1", + "core-js": "3.7.0", + "cross-env": "7.0.2", + "css-loader": "4.3.0", + "dotenv": "8.2.0", + "eslint": "7.29.0", + "eslint-config-prettier": "8.3.0", + "eslint-plugin-react-hooks": "4.2.0", + "file-loader": "6.2.0", + "fork-ts-checker-webpack-plugin": "6.0.3", + "html-webpack-plugin": "4.5.1", + "identity-obj-proxy": "3.0.0", + "jest": "26.6.3", + "jest-canvas-mock": "2.3.0", + "npm-run-all": "4.1.5", + "null-loader": "4.0.1", + "prettier": "2.2.1", + "prop-types": "15.7.2", + "random-emoji": "1.0.2", + "request": "2.88.2", + "request-promise-native": "1.0.9", + "rimraf": "3.0.2", + "shelljs": "0.8.5", + "source-map-loader": "1.1.2", + "style-loader": "2.0.0", + "time-stamp": "2.2.0", + "tmp": "0.2.1", + "ts-jest": "26.4.4", + "ts-node": "9.1.1", + "typescript": "4.1.3", + "unused-modules-webpack-plugin": "1.0.1", + "url-loader": "4.1.1", + "webpack": "4.44.2", + "webpack-cli": "3.3.12", + "webpack-dev-server": "3.11.0", + "webpack-manifest-plugin": "2.2.0", + "webpack-messages": "2.0.4", + "webpack-retry-chunk-load-plugin": "1.4.0", + "yarn-audit-fix": "^10.0.1", + "yarn-run-all": "3.1.1" + }, + "resolutions": { + "@types/react": "16.9.56" + }, + "dependencies": { + "@sentry/tracing": "6.3.6" + } +} diff --git a/apps/sim-core/packages/core/.eslintrc b/apps/sim-core/packages/core/.eslintrc new file mode 100644 index 0000000..9da1b63 --- /dev/null +++ b/apps/sim-core/packages/core/.eslintrc @@ -0,0 +1,38 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "jsx": true, + "useJSXTextNode": true + }, + "extends": [ + "plugin:@typescript-eslint/base", + "prettier" + ], + "plugins": ["@typescript-eslint", "react-hooks", "react", "import"], + "rules": { + "id-length": ["error", { + "min": 2, + "exceptions": ["_", "x", "y", "z", "a", "b"], + "properties": "never" + }], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": [ + "warn", + { + "additionalHooks": "(^useModal$)|(^useUserGatedEffect$)" + } + ], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_+", "varsIgnorePattern": "^_+" } + ], + "no-unused-expressions": "error", + "prefer-const": "error", + "react/jsx-key": "error", + "react/jsx-no-useless-fragment": "error", + "import/no-default-export": "error", + "react/self-closing-comp": "warn", + "eqeqeq": ["error", "always", { "null": "ignore" }] + } +} diff --git a/apps/sim-core/packages/core/.gitignore b/apps/sim-core/packages/core/.gitignore new file mode 100644 index 0000000..466c154 --- /dev/null +++ b/apps/sim-core/packages/core/.gitignore @@ -0,0 +1,4 @@ +coverage + +# auto-generated types for API queries +src/util/api/auto-types.ts \ No newline at end of file diff --git a/apps/sim-core/packages/core/.prettierignore b/apps/sim-core/packages/core/.prettierignore new file mode 100644 index 0000000..33e8c3d --- /dev/null +++ b/apps/sim-core/packages/core/.prettierignore @@ -0,0 +1,2 @@ +src/util/api/graphql-schema.json +src/util/api/auto-types.ts diff --git a/apps/sim-core/packages/core/README.md b/apps/sim-core/packages/core/README.md new file mode 100644 index 0000000..b37a6c3 --- /dev/null +++ b/apps/sim-core/packages/core/README.md @@ -0,0 +1,76 @@ +# Developing hCore + +### Getting started + +From a fresh clone, with [Node](https://nodejs.org/en/), [Rust](https://www.rust-lang.org/learn/get-started) and [Yarn](https://yarnpkg.com/lang/en/) installed, from the root directory of the repository run: + +```sh +$ yarn global add wasm-pack && yarn && yarn serve:core +``` + +### Troubleshooting + +#### Geospatial functionality + +To make use of geospatial and routing functionality in hCore, including the "geospatial" viewer, a Mapbox API key is needed, which can be obtained from [Mapbox.com](https://www.mapbox.com/). +To provide the key, specify an environment variable of the form `MAPBOX_API_TOKEN=your-mapbox-api-token-here` when running node. Your operating system's standard ways to provide this variable should all work, however an easy way that works in all environments is as follows: + 1. Create a new file `.env` located here in `packages/core/`. + 2. Place your environment variable in this file as a new line in the form above. + 3. Restart Node.js (repeat `yarn serve:core`) and refresh your hCore browser page. + + The geospatial tab should now be active for you. If there was an issue with the token, Mapbox will throw an error indicating the problem. + + Obtain and manage Mapbox tokens from your [Mapbox account page](https://account.mapbox.com/access-tokens/). Mapbox provides a generous free tier, but you alone will be responsible for any charges incurred while using functionality in hCore that relies upon third-party providers beyond this. + +#### Missing latest XCode Dev Tools? + +If the build gets stuck while running `wasm-pack build --target bundler --out-dir wasm/bundler --out-name hash` on macOS, make sure you have installed the latest XCode Dev Tools using" +``` +xcode-select --install +``` +If you need further debugging info, create a new file in `~/.cargo/config` with contents +``` +[net] +git-fetch-with-cli = true +``` + +#### Issues with cookies + +Our APIs are served via HTTPs and browsers are getting increasingly restrictive with regard to sending cookies between different domains when not using HTTPS. You may need to disable a browser flag in order to be able to load the IDE in your browser while developing locally (e.g. at `http://localhost:8080`). You may experience login redirect loops if you do not do this. + +- **Chrome:** go to [`chrome://flags`](chrome://flags) and disabling the `SameSite by default` flag +- **Safari:** go to `Preferences > Privacy` and disable `Prevent cross-site tracking` + +### Useful scripts + +`yarn ws:core` and `yarn ws:core` are sort of workspace helper scripts that you can use to run scripts defined in those repos' `package.json`. All of the scripts described below, and `build:core` and `serve:core` described above are run through one of these helpers. + +`yarn start:core` will serve `@hashintel/core` locally, but in production mode + +`yarn build:core` will build `@hashintel/core` in production mode, putting assets in `packages/core/dist` + +`fmt:core` will run prettier on `@hashintel/core`'s code + +`test:core` will run `@hashintel/core`'s tests + +`yarn deploy:core` will deploy `@hashintel/core` to staging (`yarn deploy:core production` will deploy to production). To use this, you need to have the aws cli installed and have it configured to an account. + +`fmt:engine-web` will run prettier on `@hashintel/engine-web`'s code + +`test:engine-web` will run the tests on `@hashintel/engine-web`'s code + +### Upgrading Pyodide + +Follow these steps to upgrade Pyodide: + + 1. Download the desired Pyodide release from the [official GitHub repository](https://github.com/pyodide/pyodide). + 2. Extract the archive and save the directory as `pyodide-`. For example `pyodide-0.17.0`. + 3. Upload the directory to S3. For example: + ``` + aws s3 cp --recursive --acl "public-read" --dryrun pyodide-0.17.0 s3://cdn-us1.hash.ai/pyodide-0.17.0/ + ``` + Remove `--dryrun` for the command to take effect. + 4. Copy the contents of `pyodide.js` from the downloaded archive and place it + into the function `getPyodideLoader` in `packages/engine-web/src/engine-web/simulation/python/pyodide.js`. + 5. Update `PYODIDE_URL` in `packages/engine-web/src/engine-web/simulation/buildpython.ts`. + diff --git a/apps/sim-core/packages/core/babel.config.js b/apps/sim-core/packages/core/babel.config.js new file mode 100644 index 0000000..8c3f167 --- /dev/null +++ b/apps/sim-core/packages/core/babel.config.js @@ -0,0 +1,11 @@ +module.exports = { + presets: [ + "@babel/preset-react", + ["@babel/preset-env", { useBuiltIns: "usage", corejs: { version: "3.8" } }], + "@babel/preset-typescript", + ], + plugins: [ + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-numeric-separator", + ], +}; diff --git a/apps/sim-core/packages/core/codegen.yml b/apps/sim-core/packages/core/codegen.yml new file mode 100644 index 0000000..ddf1d53 --- /dev/null +++ b/apps/sim-core/packages/core/codegen.yml @@ -0,0 +1,13 @@ +schema: ./src/util/api/graphql-schema.json +overwrite: true +generates: + ./src/util/api/auto-types.ts: + plugins: + - add: + content: "// tslint:disable" + - "typescript" + - "typescript-operations" + config: + skipTypename: true + maybeValue: T | null | undefined + documents: ./src/util/api/queries/*.ts diff --git a/apps/sim-core/packages/core/package.json b/apps/sim-core/packages/core/package.json new file mode 100644 index 0000000..5a95aa2 --- /dev/null +++ b/apps/sim-core/packages/core/package.json @@ -0,0 +1,160 @@ +{ + "name": "@hashintel/core", + "version": "0.1.0", + "description": "HASH Core (hCore) frontend", + "repository": "https://github.com/hashintel/labs", + "license": "AGPL-3.0-only", + "private": true, + "scripts": { + "clean": "rimraf dist", + "serve": "yarn codegen && yarn dev-env webpack-dev-server --mode development", + "prod-env": "cross-env-shell NODE_OPTIONS=--max_old_space_size=8192 NODE_ENV=production NODE_OPTIONS=--openssl-legacy-provider", + "dev-env": "cross-env-shell NODE_OPTIONS=--max_old_space_size=4096 NODE_ENV=development NODE_OPTIONS=--openssl-legacy-provider", + "build": "yarn codegen && yarn prod-env webpack --mode production", + "build-dev": "yarn codegen && yarn dev-env webpack --mode development", + "start": "yarn codegen && yarn prod-env webpack-dev-server --mode production", + "codegen": "graphql-codegen --config codegen.yml # core", + "fmt": "prettier \"*.{ts,tsx,js,jsx,json,css,scss}\" \"{scripts,src}/**/*.{ts,tsx,js,jsx,json,css,scss}\" --write; eslint --quiet --fix \"*.{ts,tsx,js}\" \"{scripts,src}/**/*.{ts,tsx,js}\"", + "fmt-check": "prettier \"*.{ts,tsx,js,jsx,json,css,scss}\" \"{scripts,src}/**/*.{ts,tsx,js,jsx,json,css,scss}\" --check || exit 1; eslint --quiet \"*.{ts,tsx,js}\" \"{scripts,src}/**/*.{ts,tsx,js}\"", + "deploy": "ts-node --project scripts/tsconfig.json scripts/deploy.ts", + "g": "ts-node --project scripts/tsconfig.json scripts/cli", + "test": "jest --forceExit --testPathIgnorePatterns '/node_modules/|/tests/e2e/'" + }, + "jest": { + "preset": "ts-jest/presets/js-with-babel", + "globals": { + "ts-jest": { + "babelConfig": true + } + }, + "moduleNameMapper": { + "^.+\\.(css|less|scss|sass)$": "identity-obj-proxy", + "^monaco-editor$": "/../../node_modules/monaco-editor/esm/vs/editor/editor.main.js", + "@hashintel/engine-web": "/src/util/simulation/mock-coreweb.ts" + }, + "setupFilesAfterEnv": [ + "/src/setupTests.ts" + ], + "transformIgnorePatterns": [ + "/node_modules/(?!monaco-editor).+\\.js$" + ] + }, + "// browserslist": [ + "this list is an `intersection` of browsers (organized as they are in `https://caniuse.com/`", + "for easier reference) that support the features we need/use, namely (really wish this was a", + "`featureslist` instead):", + "", + "We have compiled a browserlist query based on the below at https://docs.google.com/spreadsheets/d/1pCjtbvxIgnbzIbgfrJuYIk6TLHhLhqIxNAA2Iord1gE/edit#gid=0", + "" + ], + "browserslist": [ + "edge >= 79", + "firefox >= 67", + "chrome >= 66", + "safari >= 12.1" + ], + "// dependencies": [ + "We compile to a single-page app, so technically everything should be in '/devDependencies'", + "But for the sake of organization, we put our runtime dependencies here." + ], + "dependencies": { + "@deck.gl/core": "8.3.7", + "@deck.gl/layers": "8.3.7", + "@fluentui/react": "7.150.0", + "@fullstory/browser": "1.4.5", + "@hashintel/engine-web": "0.1.1", + "@hashintel/utils": "0.0.1", + "@juggle/resize-observer": "3.2.0", + "@loaders.gl/core": "*", + "@luma.gl/core": "8.3.1", + "@material-ui/core": "4.11.4", + "@material-ui/lab": "4.0.0-alpha.56", + "@msrvida/sanddance-explorer": "3.1.0", + "@reduxjs/toolkit": "1.5.0", + "@sentry/browser": "6.2.0", + "@sentry/fullstory": "1.1.5", + "@sentry/integrations": "6.2.0", + "@svgr/core": "5.5.0", + "@svgr/plugin-jsx": "5.5.0", + "@svgr/plugin-svgo": "5.5.0", + "bowser": "2.11.0", + "classnames": "2.3.1", + "clipboard-polyfill": "3.0.2", + "comlink": "4.3.0", + "css-color-names": "1.0.1", + "date-fns": "2.17.0", + "drei": "1.5.7", + "empty-module": "0.0.2", + "escape-string-regexp": "4.0.0", + "file-saver": "2.0.2", + "fp-ts": "2.9.5", + "gradient-path": "2.1.0", + "hookrouter": "1.2.3", + "idb-keyval": "5.0.2", + "js-levenshtein": "1.1.6", + "json-stringify-pretty-compact": "2.0.0", + "jszip": "3.7.0", + "line-column": "1.0.2", + "lodash-es": "4.17.21", + "mapbox-gl": "1.13.1", + "monaco-editor": "0.25.2", + "monaco-themes": "0.3.3", + "monocle-ts": "2.3.5", + "neverthrow": "4.2.1", + "plotly.js": "1.57.1", + "raw-loader": "4.0.2", + "react": "16.14.0", + "react-dom": "16.14.0", + "react-dropzone": "11.2.4", + "react-hook-form": "6.11.3", + "react-intersection-observer": "8.31.0", + "react-mapbox-gl": "5.1.1", + "react-markdown": "5.0.3", + "react-modal-hook": "3.0.0", + "react-plotly.js": "2.5.0", + "react-promise-suspense": "0.3.3", + "react-redux": "7.2.4", + "react-select": "3.1.0", + "react-shepherd": "3.3.3", + "react-splitter-layout": "4.0.0", + "react-svg": "11.1.1", + "react-tabs": "3.1.2", + "react-three-fiber": "5.0.6", + "react-timeago": "5.2.0", + "react-tiny-popover": "5.1.0", + "react-transition-group": "4.4.2", + "react-window": "1.8.6", + "recoil": "0.4.1", + "redux": "*", + "rxjs": "6.6.6", + "simplebar-react": "3.0.0-beta.6", + "slugify": "1.4.6", + "stats.js": "0.17.0", + "three": "0.119.1", + "url-join": "4.0.1", + "uuid": "8.3.1", + "vega": "5.17.3" + }, + "devDependencies": { + "@graphql-codegen/add": "2.0.2", + "@graphql-codegen/cli": "1.21.1", + "@graphql-codegen/typescript": "1.21.0", + "@graphql-codegen/typescript-operations": "1.17.14", + "@sentry/webpack-plugin": "1.15.0", + "@testing-library/react": "11.2.2", + "@testing-library/user-event": "^14.5.1", + "@types/js-levenshtein": "^1.1.1", + "@types/line-column": "^1.0.0", + "@welldone-software/why-did-you-render": "6.0.3", + "autoprefixer": "10.0.2", + "dotenv": "^16.3.1", + "eslint-plugin-import": "2.22.1", + "eslint-plugin-react": "7.22.0", + "graphql": "15.5.0", + "monaco-editor-webpack-plugin": "4.0.0", + "postcss": "8.2.13", + "postcss-loader": "4.1.0", + "sass": "1.29.0", + "sass-loader": "10.1.0" + } +} diff --git a/apps/sim-core/packages/core/scripts/cli/index.ts b/apps/sim-core/packages/core/scripts/cli/index.ts new file mode 100755 index 0000000..7b81946 --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/index.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env ts-node + +/// + +import { basename } from "path"; + +import { generateFiles } from "./utils/generateFiles"; +import { parseArgs, validateIcon } from "./utils"; + +function isString(value: string | undefined): value is string { + return value !== undefined; +} + +export function cli() { + const { dryRun, verbose, fromIcon, _ } = parseArgs(); + + // TODO: @mysterycommand - is there a way to make `yargs` do the validation? + const isValidIcon = isString(fromIcon) && validateIcon(fromIcon); + + if (isValidIcon && _.length > 1) { + throw new Error( + `Can't use \`--fromIcon\` while generating multiple components, got ${_.map( + (arg) => `"${arg}"` + ).join(", ")} with \`--fromIcon "${fromIcon}"\`` + ); + } + + // if we passed in `--fromIcon` and no component name, use the svg's basename + // n.b. `fromIcon!` is fine(?) below, because `isValidIcon` checks for string + // value, `.svg` extension, and file existence + const names = + isValidIcon && _.length === 0 ? [basename(fromIcon!, ".svg")] : _; + + names.forEach(generateFiles({ dryRun, verbose, isValidIcon, fromIcon })); +} + +cli(); diff --git a/apps/sim-core/packages/core/scripts/cli/utils/generateFile.ts b/apps/sim-core/packages/core/scripts/cli/utils/generateFile.ts new file mode 100644 index 0000000..868d76d --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/generateFile.ts @@ -0,0 +1,62 @@ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { extname, join, relative } from "path"; +import { format } from "prettier"; + +type NameContentTuple = [string, string]; +type FileGenerator = (pair: NameContentTuple) => void; +type FileGeneratorContext = { + dryRun: boolean; + verbose: boolean; + componentDir: string; +}; +type FileGeneratorFactory = (ctx: FileGeneratorContext) => FileGenerator; + +/** + * `generateFile` takes in a `FileGeneratorContext` (`dryRun` and `verbose` + * flags, and a `componentDir`) and returns a function that takes a `name` and + * `content` tuple and formats the content before writing the file to disk (or + * not depending on the flags in context) + * + * it does not write over existing files + */ +export const generateFile: FileGeneratorFactory = ({ + dryRun = false, + verbose = false, + componentDir, +}) => ([fileName, content]) => { + const filePath = join(componentDir, fileName); + + const relPath = relative(process.cwd(), filePath); + const ext = extname(filePath).substr(1); + + const fileContent = format(content, { + parser: ext === "css" ? "css" : "babel", + }); + + if (!existsSync(componentDir)) { + mkdirSync(componentDir); + } + + if (existsSync(filePath) && !dryRun) { + console.warn( + `file already exists at \`${relPath}\` ${ + dryRun ? "will skip" : "skipping" + }` + ); + return; + } + + if (verbose || dryRun) { + console.log(`\ +${dryRun ? "will write" : "writing"} file to: +\`${relPath}\` + +with \`${ext}\` contents: +${fileContent} +`); + } + + if (!dryRun) { + writeFileSync(filePath, fileContent); + } +}; diff --git a/apps/sim-core/packages/core/scripts/cli/utils/generateFiles.ts b/apps/sim-core/packages/core/scripts/cli/utils/generateFiles.ts new file mode 100644 index 0000000..58177fb --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/generateFiles.ts @@ -0,0 +1,73 @@ +import { camelCase, upperFirst } from "lodash"; +import { join } from "path"; + +import { + componentTemplate, + iconTemplate, + indexTemplate, + styleTemplate, + testTemplate, +} from "./templates"; +import { generateFile, parseIcon } from "."; + +type FilesGenerator = (name: string) => void; +type FilesGeneratorContext = { + dryRun: boolean; + verbose: boolean; + isValidIcon: boolean; + fromIcon?: string; +}; +type FilesGeneratorFactory = (ctx: FilesGeneratorContext) => FilesGenerator; + +const pascalCase = (words: string) => upperFirst(camelCase(words)); +const componentsDir = join(__dirname, "../../../src/components"); + +/** + * `generateFiles` takes in a `FilesGeneratorContext` (`dryRun` and `verbose` + * flags, an `isValidIcon` flag and optional `fromIcon` path [if `isValidIcon` + * is `true`]) and returns a function that takes in a (component) `name` and + * does some normalization on it (pascal case, inserting "Icon" if it's an icon) + * and figures out what files to generate, passing the `filePath` and + * `fileContent` pairs off to `generateFile` in the end + * + * @see ./generateFile.ts + */ +export const generateFiles: FilesGeneratorFactory = ({ + dryRun = false, + verbose = false, + isValidIcon, + fromIcon, +}) => (name) => { + const folderName = pascalCase(name); + const componentName = `${isValidIcon ? "Icon" : ""}${folderName}`; + const componentDir = join( + componentsDir, + isValidIcon ? "Icon" : "", + folderName + ); + + const styleFileName = `${componentName}.css`; + const styleFileContent = styleTemplate(componentName); + + const testFileName = `${componentName}.spec.tsx`; + const testFileContent = testTemplate(componentName); + + const componentFileName = `${componentName}.tsx`; + const componentFileContent = isValidIcon + ? iconTemplate(componentName, ...parseIcon(fromIcon!, componentName)) + : componentTemplate(componentName); + + const indexFileName = "index.ts"; + const indexFileContent = indexTemplate(componentName); + + Object.entries({ + ...(isValidIcon + ? {} + : { + [styleFileName]: styleFileContent, + }), + [testFileName]: testFileContent, + [componentFileName]: componentFileContent, + [indexFileName]: indexFileContent, + }).forEach(generateFile({ dryRun, verbose, componentDir })); +}; diff --git a/apps/sim-core/packages/core/scripts/cli/utils/index.ts b/apps/sim-core/packages/core/scripts/cli/utils/index.ts new file mode 100644 index 0000000..4ffd6aa --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/index.ts @@ -0,0 +1,4 @@ +export { generateFile } from "./generateFile"; +export { parseArgs } from "./parseArgs"; +export { parseIcon } from "./parseIcon"; +export { validateIcon } from "./validateIcon"; diff --git a/apps/sim-core/packages/core/scripts/cli/utils/parseArgs.ts b/apps/sim-core/packages/core/scripts/cli/utils/parseArgs.ts new file mode 100644 index 0000000..49259e5 --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/parseArgs.ts @@ -0,0 +1,24 @@ +import yargs from "yargs"; + +export const parseArgs = () => + yargs + .options({ + dryRun: { + alias: ["dry-run", "n"], + type: "boolean", + default: false, + }, + verbose: { + alias: "V", + type: "boolean", + default: false, + }, + fromIcon: { + alias: ["from-icon", "i"], + type: "string", + normalize: true, + }, + }) + .help("help") + .alias("help", "h") + .version(false).argv; diff --git a/apps/sim-core/packages/core/scripts/cli/utils/parseIcon.ts b/apps/sim-core/packages/core/scripts/cli/utils/parseIcon.ts new file mode 100644 index 0000000..a19141e --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/parseIcon.ts @@ -0,0 +1,44 @@ +import svgr from "@svgr/core"; +import { readFileSync } from "fs"; + +// looks for `viewBox=""` falling back to an empty +// string in the no-match (`match` returns `null`) case +const parseViewBox = (svgStr: string) => + (svgStr.match(/viewBox="([^"]*)"/) ?? ["", ""])[1]; + +const parseRect = (rectStr: string) => + rectStr.split(" ").map((number) => parseInt(number, 10)); + +const maxValue = (values: number[]) => + values.reduce((max, number) => Math.max(max, number), 0); + +export function parseIcon(path: string, name: string): [number, string] { + const jsx = svgr.sync(readFileSync(path).toString(), { + icon: true, + expandProps: false, + svgoConfig: { + plugins: [ + { removeUnusedNS: false }, + { removeAttrs: { attrs: "(fill|stroke)" } }, + { prefixIds: false }, + { + cleanupIDs: { + prefix: `${name}__`, + }, + }, + ], + }, + svgProps: { + className: `Icon ${name}`, + }, + replaceAttrValues: { + "1em": "{size}", + }, + plugins: ["@svgr/plugin-svgo", "@svgr/plugin-jsx"], + template: ({ template }, _, { jsx }) => template.ast`${jsx}`, + }); + + const size = maxValue(parseRect(parseViewBox(jsx))); + + return [size, jsx]; +} diff --git a/apps/sim-core/packages/core/scripts/cli/utils/templates.ts b/apps/sim-core/packages/core/scripts/cli/utils/templates.ts new file mode 100644 index 0000000..f9d2c24 --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/templates.ts @@ -0,0 +1,86 @@ +import { sample } from "lodash"; + +import { theme } from "../../../src/util/theme"; + +/** + * give the new component one of our brighter theme colors to make it stand out + * more … also because empty classes are a lint error … also whimsy! + */ +const themeColors = Object.keys(theme) + .filter((key) => key.match(/white|grey|dark|black/) === null) + .map((key) => `--theme-${key}`); + +/** + * @param name - the PascalCase'd name of the generated component + */ +export const styleTemplate = (name: string) => `\ +.${name} { + background: var(${sample(themeColors)}); +} +`; + +/** + * the test just asserts that this new component renders without crashing, sort + * of a `noop` but it will break as soon as you add any props to your component + * + * @param name - the PascalCase'd name of the generated component + */ +export const testTemplate = (name: string) => `\ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ${name} } from "./${name}"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render(<${name} />, div); + ReactDOM.unmountComponentAtNode(div); +}); +`; + +/** + * generates a generic functional component with an empty props type, stylesheet + * and `className` wired up + * + * @param name - the PascalCase'd name of the generated component + */ +export const componentTemplate = (name: string) => `\ +import React, { FC } from "react"; + +import "./${name}.css"; + +type ${name}Props = {}; + +export const ${name}: FC<${name}Props> = () =>
; +`; + +/** + * generates an icon component in "../../src/components/Icon" + * + * @param name - the PascalCase'd name of the generated component (should be + * like `IconWhatever`) + * @param size - the largest of either the width or height of the icon + * relies on `preserveAspectRatio` defaulting to `xMidYMid meet` + * @param jsx - the `jsx` of the icon (generated from `svg` source via + * `@svgr/core`) + * + * @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio + * @see ./parseIcon.ts + */ +export const iconTemplate = (name: string, size: number, jsx: string) => `\ +import React, { FC } from "react"; + +import { IconProps } from ".."; +import "../Icon.css"; + +export const ${name}: FC = ({ size = ${size} }) => ${jsx} +`; + +/** + * the reexporting index.ts that makes our "component folder pattern" work + * + * @param name - the PascalCase'd name of the generated component + */ +export const indexTemplate = (name: string) => `\ +export { ${name} } from "./${name}"; +`; diff --git a/apps/sim-core/packages/core/scripts/cli/utils/validateIcon.ts b/apps/sim-core/packages/core/scripts/cli/utils/validateIcon.ts new file mode 100644 index 0000000..6fe61b2 --- /dev/null +++ b/apps/sim-core/packages/core/scripts/cli/utils/validateIcon.ts @@ -0,0 +1,19 @@ +import { existsSync } from "fs"; +import { extname } from "path"; + +export function validateIcon(iconPath: string): boolean { + const isSvg = extname(iconPath) === ".svg"; + const isFile = existsSync(iconPath); + const isValidIcon = isSvg && isFile; + + if (iconPath && !isValidIcon) { + throw new Error( + `Got \`--fromIcon "${iconPath}"\`, but ${[ + ...(isSvg ? [] : ["it's missing the `.svg` extension"]), + ...(isFile ? [] : ["there's no file at that location"]), + ].join(", and ")}` + ); + } + + return isValidIcon; +} diff --git a/apps/sim-core/packages/core/scripts/deploy.ts b/apps/sim-core/packages/core/scripts/deploy.ts new file mode 100644 index 0000000..039ecba --- /dev/null +++ b/apps/sim-core/packages/core/scripts/deploy.ts @@ -0,0 +1,192 @@ +// Requires awscli & proper credentials to be installed in your console session. +// Invoke this script with 'npm run deploy' or 'npx ts-node deploy.ts' + +/// + +import randomEmoji from "random-emoji"; +import request from "request-promise-native"; +import { cat, config, echo, exec, ls, which } from "shelljs"; +import { trimStart } from "lodash"; + +const logging_prefix: string = randomEmoji + .random({ count: 2 }) + .map((emoji: any) => emoji.character) + .join(""); + +const S3_BUCKET = "core.hash.ai"; + +const userName = ( + exec(`git config --global --get user.name`).stdout || exec(`whoami`).stdout +).trim(); + +function getNotifier(notifySlack: boolean) { + if (!notifySlack) { + echo("Running Silent."); + } + return async (message: string) => { + echo(message); + + if (notifySlack) { + await request.post( + "https://hooks.slack.com/services/T5Z49HZPW/B01QB9PQNE8/4Ur5MWKteJFdxvGYCCxiD", + { json: { text: `${logging_prefix} ${message}` } } + ); + } + }; +} + +/** + * Builds assets from our environment and uploads to S3. + * + * @return {string} The build stamp + */ +async function buildAndStageAssets(): Promise { + // 1. clean build: + exec("yarn clean"); + exec("yarn build"); + + // 2. sync to bucket + // find our manifest file: + const manifests = ls("dist/**/manifest.json"); + echo("Found manifest", JSON.stringify(manifests)); + if (manifests.length > 1) { + throw "Found multiple manifest files, Aborting."; + } + if (manifests.length === 0) { + throw "Couldn't find a manifest."; + } + const manifest = JSON.parse(cat(manifests[0])); + if (!manifest || !manifest.BUILD_STAMP) { + throw "Build stamp not found in manifest, aborting."; + } + const stamp = manifest.BUILD_STAMP; + + echo("Build stamp is", stamp); + + exec(`rm -rf dist/${stamp}/*.js.map`); + + // 3. upload everything in our build folder to s3: + exec(`aws s3 sync dist/${stamp}/ s3://${S3_BUCKET}/${stamp}/`); + + // S3 infers wasm mime types wrong, so fix them: + Object.keys(manifest) + .filter((key) => key.endsWith(".wasm")) + .forEach((key) => { + echo("Correcting mime type for", key); + const wasm = trimStart(manifest[key], "/"); + const s3Path = `s3://${S3_BUCKET}/${wasm}`; + + // Copy the wasm file onto itself so we can mutate its metadata to set content-type wasm + exec( + `aws s3 cp ${s3Path} ${s3Path} --content-type application/wasm --metadata-directive REPLACE` + ); + + // Check our work: + exec(`aws s3api head-object --bucket ${S3_BUCKET} --key ${wasm}`); + }); + + // 4. Notify relevant apps: + // Notify Sentry.io that a new deploy exists + await request.post( + "https://sentry.io/api/hooks/release/builtin/1509252/efc0273443d500ecd145d41ac4e0b48999648d378b828dca8b4a1b8fb3d42ef8/", + { json: { version: stamp } } + ); + + return stamp; +} + +/** + * Sets {stamp} as the live version. Invoke from the repo root like: + * + * ```sh + * yarn deploy:core live {stamp} + * ``` + * + * S3 routes everything to /index.html, and so does cloudfront. The index.html + * file we generate is path-aware; it expects /{stamp} to exist with all its + * assets. In this manner, everyone will route by default to index.html, and + * any specialist can go to {stamp}/index.html to load up that version instead + * (Need to do this manually? The command is: + * + * ```sh + * aws s3 cp s3://core.hash.ai/{stamp}/index.html s3://core.hash.ai/index.html + * ``` + * + * @param stamp -- build stamp to deploy (should already be present in s3) + */ +async function setLive(stamp: string) { + const rootIndexPath = `s3://${S3_BUCKET}/index.html`; + const manifestIndexPath = `s3://${S3_BUCKET}/${stamp}/index.html`; + const rootEmbedPath = `s3://${S3_BUCKET}/embed.html`; + const manifestEmbedPath = `s3://${S3_BUCKET}/${stamp}/embed.html`; + + try { + exec( + `aws s3api head-object --bucket ${S3_BUCKET} --key ${stamp}/index.html` + ); + } catch (err) { + console.error("Build stamp not found in s3", stamp); + process.exit(1); + } + exec( + `aws s3 cp ${manifestIndexPath} ${rootIndexPath} --cache-control no-cache --content-type text/html --metadata-directive REPLACE` + ); + + // Older builds may not have an embed.html (linked to when embedding hCore) + try { + exec( + `aws s3 cp ${manifestEmbedPath} ${rootEmbedPath} --cache-control no-cache --content-type text/html --metadata-directive REPLACE` + ); + } catch (err) { + console.warn("*** Build does not contain an embed.html! ***"); + } +} + +async function run() { + // Shelljs is awesome, docs here: https://github.com/shelljs/shelljs + config.fatal = true; // raise an exception if a shell command errors out + + if (!which("aws")) { + console.error("AWS CLI not found, aborting."); + process.exit(1); + } + + // If arg is 'silent', don't tell Slack about what's going on. + // Only works for non-live deploys; live deployments always notify. + const notify = getNotifier(process.argv[2] !== "silent"); + + try { + const live = process.argv[2] === "live"; + if (live) { + const stamp = process.argv[3]; + if (!stamp) { + throw "Usage: deploy live "; + } + setLive(stamp); + await notify(`Live version now set to \`${stamp}\` by ${userName}`); + await notify(`Permalink: \`https://core.hash.ai/${stamp}/index.html\``); + } else { + const head = exec("git rev-parse HEAD").stdout.trim(); + const commitUrl = `https://github.com/hashintel/internal/commit/${head}`; + const localChanges = exec("git status --porcelain").stdout !== ""; + const stamp = await buildAndStageAssets(); + console.log("Staged: ", stamp); + // Notify slack that the version is staged: + await notify( + [ + `${userName} staged \`${stamp}\``, + `Preview at: \`https://staging.hash.ai/${stamp}/index.html\``, + `Built from: \`${commitUrl}\` ${ + localChanges ? "(plus local modifications)" : "" + }`, + ].join("\n") + ); + } + } catch (err) { + console.error(`Deploy failed with error:`); + console.error("```" + err.toString() + "```"); + process.exit(1); + } +} + +run(); diff --git a/apps/sim-core/packages/core/scripts/tsconfig.json b/apps/sim-core/packages/core/scripts/tsconfig.json new file mode 100644 index 0000000..09ab7ac --- /dev/null +++ b/apps/sim-core/packages/core/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "target": "es2019" + } +} diff --git a/apps/sim-core/packages/core/scripts/types.d.ts b/apps/sim-core/packages/core/scripts/types.d.ts new file mode 100644 index 0000000..8831c9d --- /dev/null +++ b/apps/sim-core/packages/core/scripts/types.d.ts @@ -0,0 +1,29 @@ +declare module "@svgr/core" { + export function sync( + code: string, + opts: Partial<{ + // @see: https://www.smooth-code.com/open-source/svgr/docs/options/ + configFile: string; + ext: string; + icon: boolean; + native: boolean | { expo: true }; + dimensions: boolean; + expandProps: "start" | "end" | false; + prettier: boolean; + prettierConfig: { [key: string]: any }; + svgo: boolean; + svgoConfig: { + plugins: { [key: string]: any }[]; + }; + ref: boolean; + replaceAttrValues: { [key: string]: string }; + svgProps: { [key: string]: string }; + title: boolean; + template: ({ template }: any, _: any, { jsx }: any) => string; + // only partially documented, but necessary + // @see: https://www.smooth-code.com/open-source/svgr/docs/node-api/#plugins + plugins: string[]; + }> + ): string; +} +declare module "random-emoji"; diff --git a/apps/sim-core/packages/core/site.d.ts b/apps/sim-core/packages/core/site.d.ts new file mode 100644 index 0000000..6ce8b2b --- /dev/null +++ b/apps/sim-core/packages/core/site.d.ts @@ -0,0 +1,24 @@ +/** + * provided by webpack build + * @see: ./webpack.config.js + */ +declare var WEBPACK_PUBLIC_PATH: string; +declare var WEBPACK_BUILD_STAMP: string; +declare var LOCAL_API: boolean; +declare var MAPBOX_API_TOKEN: string; + +/** + * Like `Omit` but distributes over unions + * @see https://davidgomes.com/pick-omit-over-union-types-in-typescript/ + */ +type DistributiveOmit = T extends unknown + ? Omit + : never; + +/** + * Like `Pick` but distributes over unions + * @see https://davidgomes.com/pick-omit-over-union-types-in-typescript/ + */ +type DistributivePick = T extends unknown + ? Pick + : never; diff --git a/apps/sim-core/packages/core/src/boot.ts b/apps/sim-core/packages/core/src/boot.ts new file mode 100644 index 0000000..27384ad --- /dev/null +++ b/apps/sim-core/packages/core/src/boot.ts @@ -0,0 +1,36 @@ +import { enableMapSet } from "immer"; + +import * as api from "./util/api"; +import { buildSimulationProvider } from "./features/simulator/simulate/buildprovider"; +import { configureMonaco } from "./util/monaco-config"; +import { initSentry } from "./util/initSentry"; +import { resizeObserverPromise } from "./util/resizeObserverPromise"; +import { simulatorStore } from "./features/simulator/store"; +import { store } from "./features/store"; +import { syncStores } from "./features/simulator/simulate/sync"; +import { theme } from "./util/theme"; + +const configureTheme = () => { + const { style } = document.documentElement; + for (const [key, value] of Object.entries(theme)) { + style.setProperty(`--theme-${key}`, value); + } +}; + +export const boot = async (forExperiments: boolean) => { + // Expose for console access: + Object.assign(window as any, { + api, + store, + simulatorStore, + }); + + initSentry(); + configureTheme(); + enableMapSet(); + configureMonaco(); + buildSimulationProvider(forExperiments); + syncStores(store, simulatorStore); + + await resizeObserverPromise; +}; diff --git a/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.scss b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.scss new file mode 100644 index 0000000..7807354 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.scss @@ -0,0 +1,10 @@ +.ActivityEmpty { + user-select: none; + text-align: center; + margin: auto; + font-size: 12px; + line-height: 15px; + color: rgba(255, 255, 255, 0.5); + max-width: 127px; + padding: 8px; +} diff --git a/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.tsx b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.tsx new file mode 100644 index 0000000..01350bb --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityEmpty.tsx @@ -0,0 +1,7 @@ +import React, { FC } from "react"; + +import "./ActivityEmpty.scss"; + +export const ActivityEmpty: FC = ({ children }) => ( +
{children}
+); diff --git a/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.scss b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.scss new file mode 100644 index 0000000..0ea88b8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.scss @@ -0,0 +1,127 @@ +.ActivityHistory { + --experiments-icon-spacing: 4px; + --padding-x: 19px; + + background-color: var(--theme-dark); + position: relative; + + width: 100%; + height: 100%; + overflow: auto; + + font-size: 13px; + color: white; +} + +.ActivityHistory, +.ActivityHistory__Container { + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.ActivityHistory__Container--no-content { + height: 100%; +} + +.ActivityHistory__Items { + margin: -1px 0 0; + padding: 0; + + > :last-child { + margin-bottom: calc(0px - var(--scroll-fade-shadow-height)); + } +} + +.ActivityHistory__Items__Loading { + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +/** + * This ensures the shadow always shows at the bottom, even when its animating + * out after the filter has been changed so there aren't enough items to + * scroll anymore + */ +.ActivityHistory__FadeSpacer { + height: 0; + flex-grow: 1; +} + +.ActivityHistory__Fade { + display: block; + position: sticky !important; + bottom: -1px !important; + z-index: 2; + flex: 0 0 auto; +} + +.ActivityHistory__Header { + flex: 0 0 auto; + display: flex; + height: 45px; + align-items: center; + padding: 0 var(--padding-x); + + position: sticky; + top: 0; + background-color: var(--theme-dark); + z-index: 3; + + // There's a weird bug with position sticky where you can see + // a small gap above it in some scenarios – this covers that gap + &:before { + content: ""; + display: block; + height: 1px; + background-color: var(--theme-dark); + top: 0; + left: 0; + right: 0; + position: absolute; + } + + h2 { + user-select: none; + text-transform: uppercase; + font-size: 12px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0; + margin: 0 5px 0 0; + } + + .IconLoading { + margin-left: auto; + } +} + +.ActivityHistory__Header__Select { + fill: currentColor; + top: -1px; + margin-left: auto; + font-size: 12px; + user-select: none; + + .IconArrowDownDrop { + top: -1px; + position: relative; + } +} + +.ActivityHistory__Header__Border { + position: sticky; + top: 45px; + height: 1px; + background-color: var(--theme-border); + pointer-events: none; + left: 0; + right: 0; + z-index: 1; + margin-left: var(--padding-x); + flex-shrink: 0; +} diff --git a/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.tsx b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.tsx new file mode 100644 index 0000000..da8ae87 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ActivityHistory/ActivityHistory.tsx @@ -0,0 +1,187 @@ +import React, { FC, useCallback, useRef, useState } from "react"; +import { useSelector } from "react-redux"; +import SplitterLayout from "react-splitter-layout"; +import classNames from "classnames"; + +import { ActivityEmpty } from "./ActivityEmpty"; +import { ActivityHistoryItemCommitGroup } from "./ActivityHistoryItemCommitGroup"; +import { ActivityHistoryRelease } from "./ActivityHistoryRelease"; +import { ActivityHistorySingleRun } from "./SingleRun/ActivityHistorySingleRun"; +import { AgentInspector } from "./Inspector/Inspector"; +import { ExperimentGroup } from "./ExperimentGroup/ExperimentGroup"; +import { IconLoading } from "../Icon/Loading"; +import { IconSpinner } from "../Icon"; +import { Scope, useScope } from "../../features/scopes"; +import { ScrollFadeShadow } from "../ScrollFade/ScrollFadeShadow"; +import { Select } from "../Inputs/Select/Select"; +import { SimulatorHistoryItemType } from "../../features/simulator/simulate/types"; +import { + historySelectors, + selectHistoryComplete, +} from "../../features/simulator/simulate/selectors"; +import { selectProjectRef } from "../../features/project/selectors"; +import { theme } from "../../util/theme"; +import { useInfiniteScrollingHistory } from "./hooks"; +import { useScrollState } from "../../hooks/useScrollState"; +import { useSimulatorSelector } from "../../features/simulator/context"; + +import "./ActivityHistory.scss"; + +type FilterOption = "Experiments" | "Single Runs" | "Releases" | "Commits"; + +const filterOptionToItemType: Record = { + "Single Runs": SimulatorHistoryItemType.SingleRun, + Experiments: SimulatorHistoryItemType.ExperimentRun, + Releases: SimulatorHistoryItemType.Release, + Commits: SimulatorHistoryItemType.CommitGroup, +}; + +export const ActivityHistory: FC<{ visible: boolean }> = ({ visible }) => { + const containerRef = useRef(null); + const [setScrollStateRef, fadeVisible, scrollable] = useScrollState(); + const historyComplete = useSimulatorSelector(selectHistoryComplete); + const [ + spinnerRef, + shouldShowHistory, + historyInitialized, + ] = useInfiniteScrollingHistory(containerRef, visible); + const canEdit = useScope(Scope.edit); + const projectRef = useSelector(selectProjectRef); + + const historyItemsFromStore = useSimulatorSelector( + historySelectors.selectAll + ); + + const [selected, setSelected] = useState<"All" | FilterOption>("All"); + const historyItems = (selected === "All" + ? historyItemsFromStore + : historyItemsFromStore.filter( + (item) => item.itemType === filterOptionToItemType[selected] + ) + ).map((item) => { + switch (item.itemType) { + case SimulatorHistoryItemType.ExperimentRun: + return ; + + case SimulatorHistoryItemType.SingleRun: + return ( + + ); + + case SimulatorHistoryItemType.Release: + return ( + + ); + + case SimulatorHistoryItemType.CommitGroup: + return ( + + ); + } + }); + + const setActivityHistoryRef = useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node; + setScrollStateRef(node); + }, + [setScrollStateRef] + ); + + return ( +
+ +
+ {/** + * This second container is needed due to a bug with position sticky in Safari + * @see https://stackoverflow.com/a/57938266/851985 + */} +
+
+

Activity

+ {historyInitialized ? ( + onChange(evt.target.value)} + type="color" + value={value} + /> + +
+); + +const Toggler: FC<{ + toggleFn: () => void; + checked: boolean; + label: string; +}> = ({ toggleFn, checked, label }) => { + return ( +
+ + +
+ ); +}; + +const SampleLevelSlider: FC = () => { + const [sampleLevel, setSampleLevel] = useRecoilState(SampleLevel); + return ( +
+ setSampleLevel(val as number)} + step={1} + type="number" + value={sampleLevel} + /> + +
+ ); +}; diff --git a/apps/sim-core/packages/core/src/components/AgentScene/components/Stage.tsx b/apps/sim-core/packages/core/src/components/AgentScene/components/Stage.tsx new file mode 100644 index 0000000..026ebed --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/components/Stage.tsx @@ -0,0 +1,73 @@ +import React, { FC } from "react"; +import { useRecoilValue } from "recoil"; + +import * as sceneState from "../state/SceneState"; + +/** + * Create a baseplate that scales with agents as they move, based on the agents + * furthest from the center of the map. + */ +export const ViewerStage: FC = () => { + // A Square grid centered around the middle of the agents + const dims = useRecoilValue(sceneState.StageDimensions); + const showGrid = useRecoilValue(sceneState.GridEnabled); + const showFloor = useRecoilValue(sceneState.FloorEnabled); + const showAxes = useRecoilValue(sceneState.AxesEnabled); + const stageColor = useRecoilValue(sceneState.StageColor); + const gridColor = useRecoilValue(sceneState.GridColor); + + // Position of the grid to the center of the min/max + const { pxMax, pxMin, pyMax, pyMin } = dims; + const [[centerX, centerY], width] = getStagePlacement( + pxMax, + pxMin, + pyMax, + pyMin + ); + + return ( + <> + 100 ? width / 2 : width, gridColor, gridColor]} + rotation={[Math.PI / 2, Math.PI / 2, 0]} + position={[centerX, centerY, 0]} + visible={showGrid} + /> + + + + + + + + + ); +}; + +function getStagePlacement( + pxMax: number, + pxMin: number, + pyMax: number, + pyMin: number +) { + const center: [number, number] = [ + Math.floor((pxMax + pxMin) / 2), + Math.floor((pyMax + pyMin) / 2), + ]; + const width = Math.floor(Math.max(pxMax - pxMin, pyMax - pyMin)); + return [center, width] as const; +} diff --git a/apps/sim-core/packages/core/src/components/AgentScene/state/SceneState.ts b/apps/sim-core/packages/core/src/components/AgentScene/state/SceneState.ts new file mode 100644 index 0000000..4df274d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/state/SceneState.ts @@ -0,0 +1,188 @@ +//! Manage the state of the 3d viewer from one place + +import { AtomEffect } from "recoil"; + +import { AgentTransition, RenderSummary } from "../util/anim"; +import { autoAtom, autoSelector, autoreadSelectorFamily } from "./util"; +import { getItem, setItem } from "../../../hooks/useLocalStorage"; +import { projectChangeObservable } from "../../../features/project/observables"; +import { selectProjectPathWithNamespace } from "../../../features/project/selectors"; +import { store } from "../../../features/store"; + +//----------------------- 3D Mesh Data & Transitions ------------------------/ +export const MappedTransitions = autoAtom({ + default: {} as RenderSummary, + + // A little faster, and lets us do modifications in place instead of having to reallocate + // We still make sure to use "setMappedTransitons" when intending to update subscribers + dangerouslyAllowMutability: true, +}); + +export const SelectedAgentIds = autoAtom({ + default: {} as { + [id: string]: true; + }, +}); + +export const SelectedAgentData = autoreadSelectorFamily({ + get: (agentId: string) => ({ get }): AgentTransition | null | undefined => { + const transitions = get(MappedTransitions); + return transitions[agentId]; + }, + + // Because we're selecting agent data, we *also* need to allow mutability + dangerouslyAllowMutability: true, +}); + +export const SelectedMeshes = autoSelector({ + get: ({ get }) => { + const selectedAgents = get(SelectedAgentIds); + const mappedTransitions = get(MappedTransitions); + + const meshes: RenderSummary = {}; + for (const id of Object.keys(selectedAgents)) { + const transition = mappedTransitions[id]; + if (transition) { + meshes[id] = transition; + } + } + return meshes; + }, +}); + +// Break the input summary apart into groups based on meshes which get passed on to the individual meshes +export const PositionedMeshes = autoSelector({ + get: ({ get }) => { + const mappedTransitions = get(MappedTransitions); + const meshes: { + [meshType: string]: RenderSummary; + } = {}; + for (const [id, agent] of Object.entries(mappedTransitions)) { + if (!meshes.hasOwnProperty(agent.shape)) { + meshes[agent.shape] = {}; + } + meshes[agent.shape][id] = agent; + } + return meshes; + }, +}); +export const ShapedMeshes = autoreadSelectorFamily({ + get: (shape: string) => ({ get }) => { + const meshes = get(PositionedMeshes); + if (shape === "pickedAgent") { + const selected = get(SelectedAgentIds); + const transitions = get(MappedTransitions); + const output: RenderSummary = {}; + for (const id of Object.keys(selected)) { + const trans = transitions[id]; + if (trans) { + output[id] = trans; + } + } + return output; + } else { + return meshes[shape] ?? {}; + } + }, +}); +export const ShapedMeshesEntries = autoreadSelectorFamily({ + get: (shape: string) => ({ get }) => { + const meshes = get(ShapedMeshes(shape)); + return Object.entries(meshes); + }, +}); + +type HoveredAgent = string | null; +export const HoveredAgent = autoAtom({ + default: null as HoveredAgent, +}); + +//----------------------- 3D Viewer Settings ------------------------/ + +// For each setting, store the lastSet value plus any project-specific value +type ViewerSettingValue = number | string | boolean; +type ViewerSettingsStorageObject = { + lastSet: ViewerSettingValue; + [projectPath: string]: ViewerSettingValue; +}; + +// Persist and retrieve 3D settings state to localStorage, +// for settings configurable in SceneSettings +const localStorageSyncEffect = ( + settingName: string +) => ({ setSelf, onSet }: Parameters>[0]) => { + const storageKey = `sceneSettings.${settingName}`; + + const getProjectPath = () => selectProjectPathWithNamespace(store.getState()); + + const loadValueFromLocalStorage = () => { + const currentProjectPath = getProjectPath(); + + // Get the last used value for this setting, if any + const savedSettings = getItem(storageKey); + let savedSetting = savedSettings?.lastSet; + + // If we have a project-specific value for this setting, prefer it + if (currentProjectPath && savedSettings?.[currentProjectPath]) { + savedSetting = savedSettings[currentProjectPath]; + } + + if (savedSetting != null) { + setSelf(savedSetting as T); + } + }; + + projectChangeObservable(store).subscribe(() => { + loadValueFromLocalStorage(); + }); + + // Called when the atom is updated from elsewhere (e.g. on user input) + onSet((newValue) => { + const currentProjectPath = getProjectPath(); + + // Store the value as last set and (if project scoped) project-specific + const savedSettings: ViewerSettingsStorageObject = { + ...(getItem(storageKey) ?? {}), + lastSet: newValue as ViewerSettingValue, + }; + if (currentProjectPath) { + savedSettings[currentProjectPath] = newValue as ViewerSettingValue; + } + + setItem(storageKey, savedSettings); + }); +}; + +const settingAtom = ( + key: string, + defaultValue: T +) => + autoAtom({ + default: defaultValue, + effects_UNSTABLE: [localStorageSyncEffect(key)], + }); + +// The settings configurable in SceneSettings +export const SceneView = settingAtom<"3d" | "2d">("view", "3d"); +export const CameraFov = settingAtom("fov", 30); +export const StageColor = settingAtom("stageColor", "#111216"); +export const GridColor = settingAtom("gridColor", "#444444"); +export const GridEnabled = settingAtom("gridEnabled", true); +export const FloorEnabled = settingAtom("floorEnabled", true); +export const AxesEnabled = settingAtom("axesEnabled", true); +export const EdgesEnabled = settingAtom("edgesEnabled", true); +export const UpdatesEnabled = settingAtom("updatesEnabled", true); +export const LightEnabled = settingAtom("lightEnabled", true); +export const StatsEnabled = settingAtom("statsEnabled", false); +export const SampleLevel = settingAtom("sampleLevel", 3); + +//----------------------- Stage Dimensions ------------------------/ +export const dimensionDefaults = { + pxMax: 10, + pxMin: -10, + pyMax: 10, + pyMin: -10, +}; +export const StageDimensions = autoAtom({ + default: dimensionDefaults, +}); diff --git a/apps/sim-core/packages/core/src/components/AgentScene/state/resetViewer.ts b/apps/sim-core/packages/core/src/components/AgentScene/state/resetViewer.ts new file mode 100644 index 0000000..8840632 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/state/resetViewer.ts @@ -0,0 +1,48 @@ +import { CallbackInterface } from "recoil"; + +import { + HoveredAgent, + SelectedAgentIds, + StageDimensions, + dimensionDefaults, +} from "./SceneState"; +import { selectGlobals } from "../../../features/files/selectors"; +import { store } from "../../../features/store"; + +/** + * Set the dimensions of the viewer stage on viewer reset + * Also clears some recoil state + * @see Controls for camera reset + */ +export const resetViewer = ({ set, reset }: CallbackInterface) => async () => { + // Set the dimensions of the stage on reset + let { pxMin, pxMax, pyMin, pyMax } = dimensionDefaults; + + // The user may have defined their own initial stage bounds in globals.json + const globals = selectGlobals(store.getState()); + if (globals) { + try { + const { topology } = JSON.parse(globals); + if (topology) { + // Fallback to default values if user has failed to specify any + pxMin = topology.x_bounds?.[0] ?? pxMin; + pxMax = topology.x_bounds?.[1] ?? pxMax; + pyMin = topology.y_bounds?.[0] ?? pyMin; + pyMax = topology.y_bounds?.[1] ?? pyMax; + } + } catch { + // globals.json is not valid JSON + } + } + + set(StageDimensions, { + pxMin, + pxMax, + pyMin, + pyMax, + }); + + // Clear selected agents while we're at it + reset(SelectedAgentIds); + reset(HoveredAgent); +}; diff --git a/apps/sim-core/packages/core/src/components/AgentScene/state/updateTransitionMap.ts b/apps/sim-core/packages/core/src/components/AgentScene/state/updateTransitionMap.ts new file mode 100644 index 0000000..72e02f4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/state/updateTransitionMap.ts @@ -0,0 +1,171 @@ +import * as THREE from "three"; +import { AgentState, Vec3 } from "@hashintel/engine-web"; +import { CallbackInterface } from "recoil"; + +import { AgentTransition, RenderSummary } from "../util/anim"; +import { MappedTransitions, StageDimensions } from "./SceneState"; + +const tempColor = new THREE.Color(); + +/* +Uses the previous RenderSummary to create a new RenderSummary that describes +transitions that the 3D viewer must take. +*/ +export const updateTransitionMap = ({ + set, + snapshot, +}: CallbackInterface) => async ( + oldSummary: RenderSummary, + states: AgentState[] +) => { + /** + * Update the agents themselves + */ + const removals = new Set(Object.keys(oldSummary)); + const newSummary = { ...oldSummary }; + for (const agent of states) { + const agentId = agent.agent_id ?? "AGENT_ID_NOT_FOUND"; + if (!agent.position) { + continue; + } + + /* + OF NOTE: + This agent data is coming from recoil. We can "dangerouslyMutate" it while + making sure it gets committed back using the normal recoilSetState. + */ + // This can be undefined, dont' rely on it + const oldAgent = newSummary[agentId] as AgentTransition | undefined; + + // 1. Extract position + const [posX, posY, posZ] = [...(agent.position ?? [1, 1, 1])]; + const newPosition: Vec3 = [posX, posY ?? 0, posZ ?? 0]; + + // 2. Extract scaling + const scalex = agent.scale ? agent.scale[0] : 1; + const scaley = agent.scale ? agent.scale[1] : 1; + const scalez = agent.height ?? (agent.scale ? agent.scale[2] : 1); + const newScale: Vec3 = [scalex, scaley, scalez]; + const useHeight = agent.scale === undefined || agent.height !== undefined; + + // 3. Extract Direction + const [newDirX, newDirY, newDirZ] = [ + ...((Array.isArray(agent.direction) ? agent.direction : null) ?? + (Array.isArray(agent.velocity) ? agent.velocity : null) ?? [0, 0, 0]), + ]; + + // If the velocity goes to zero, try using the previous state's direction + // This helps prevent agents from flipping rotations around + const newDirection: Vec3 = [newDirX ?? 0, newDirY ?? 0, newDirZ ?? 0]; + if ( + newDirection[0] === 0 && + newDirection[1] === 0 && + newDirection[2] === 0 + ) { + const oldDir = oldAgent?.direction.to ?? oldAgent?.direction.current; + if (oldDir) { + newDirection[0] = oldDir[0]; + newDirection[1] = oldDir[1]; + newDirection[2] = oldDir[2]; + } + } + + // 4. Extract Color + // + // Agents can have a "color" field and even an "rgb" field + // RGB is specified is [r,g,b] whereas color is any three-compatible color description + tempColor.set(agent.color ?? "green"); + const newColor: Vec3 = [tempColor.r, tempColor.g, tempColor.b]; + if (agent.rgb && !agent.color) { + newColor[0] = agent.rgb[0] / 255; + newColor[1] = agent.rgb[1] / 255; + newColor[2] = agent.rgb[2] / 255; + } + + // Weird carry over from before, any agents with a direction but no shape + // are turned into "arrows" (ie pointed cones) + let shape = agent.shape ?? oldAgent?.shape; + if (!shape) { + if (agent.direction || agent.velocity) { + shape = "cone"; + } else { + shape = "box"; + } + } + + // 5. Assign a slot in the transitions + // + // Grab out any old data from the agent to act as the previous animation frame + if (oldAgent) { + newSummary[agentId] = { + ...oldAgent, + shape, + original: agent, + hidden: agent.hidden ?? false, + color: { current: [...oldAgent.color.to], to: newColor }, + direction: { current: oldAgent.direction.to, to: newDirection }, + scale: { current: oldAgent.scale.to, to: newScale }, + position: { current: oldAgent.position.to, to: newPosition }, + network_neighbor_ids: agent.network_neighbor_ids, + network_neighbor_in_ids: agent.network_neighbor_in_ids, + network_neighbor_out_ids: agent.network_neighbor_out_ids, + }; + } else { + // If no existing agent exists, create a new one + newSummary[agentId] = { + color: { current: newColor, to: newColor }, + direction: { current: newDirection, to: newDirection }, + position: { current: newPosition, to: newPosition }, + scale: { current: [0, 0, 0], to: newScale }, + network_neighbor_ids: agent.network_neighbor_ids, + network_neighbor_in_ids: agent.network_neighbor_in_ids, + network_neighbor_out_ids: agent.network_neighbor_out_ids, + useHeight, + remove: false, + shape, + original: agent, + hidden: agent.hidden ?? false, + }; + } + removals.delete(agentId); + } + + // ID didn't show up in the new list of agents + for (const removal of removals.values()) { + const oldAgent = newSummary[removal]; + // Make sure this is actually actionable + if (oldAgent) { + if (oldAgent.remove) { + // Either it was scheduled for deletion or should be deleted + delete newSummary[removal]; + } else { + // Or it needs to be scheduled for deletion + newSummary[removal] = { + ...oldAgent, + remove: true, + scale: { ...oldAgent.scale, to: [0, 0, 0] }, + }; + } + } + } + + set(MappedTransitions, newSummary); + + /** + * Set the dimensions of the stage + */ + const dims = await snapshot.getPromise(StageDimensions); + let { pxMax, pxMin, pyMax, pyMin } = dims; + for (const agent of Object.values(newSummary)) { + pxMax = Math.max(agent.position.to[0], pxMax); + pxMin = Math.min(agent.position.to[0], pxMin); + pyMax = Math.max(agent.position.to[1], pyMax); + pyMin = Math.min(agent.position.to[1], pyMin); + } + set(StageDimensions, { + pxMax, + pxMin, + pyMax, + pyMin, + }); +}; diff --git a/apps/sim-core/packages/core/src/components/AgentScene/state/util.ts b/apps/sim-core/packages/core/src/components/AgentScene/state/util.ts new file mode 100644 index 0000000..f9fa4e6 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/state/util.ts @@ -0,0 +1,51 @@ +import { + AtomOptions, + ReadOnlySelectorFamilyOptions, + ReadOnlySelectorOptions, + SerializableParam, + atom as recoilAtom, + selector as recoilSelector, + selectorFamily, +} from "recoil"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Generates a guaranteed-to-be-unique atom + * Is *not* compatible with deserialization + * + * @param options Normal selector options without key + */ +export function autoAtom(options: Omit, "key">) { + return recoilAtom({ + key: uuidv4(), + ...options, + }); +} + +export function autoSelector( + options: Omit, "key"> +) { + return recoilSelector({ + key: uuidv4(), + ...options, + cachePolicy_UNSTABLE: { + // needed until Recoil has better default memory management + // otherwise ALL input/output is cached forever + eviction: "most-recent", + }, + }); +} + +export function autoreadSelectorFamily( + options: Omit, "key"> +) { + return selectorFamily({ + key: uuidv4(), + ...options, + cachePolicy_UNSTABLE: { + // needed until Recoil has better default memory management + // otherwise ALL input/output is cached forever + eviction: "most-recent", + }, + }); +} diff --git a/apps/sim-core/packages/core/src/components/AgentScene/util/anim.ts b/apps/sim-core/packages/core/src/components/AgentScene/util/anim.ts new file mode 100644 index 0000000..c19608a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/util/anim.ts @@ -0,0 +1,45 @@ +import { AgentState, Vec3 } from "@hashintel/engine-web"; + +export type AnimValue
= { current: A; to: A }; +export type AgentTransition = { + position: AnimValue; + direction: AnimValue; + color: AnimValue; + scale: AnimValue; + useHeight: boolean; + remove: boolean; + original: AgentState; + shape: string; + hidden: boolean; + // we check network_neighbor_* fields and only use them if they are string[] + network_neighbor_ids?: unknown; + network_neighbor_in_ids: unknown; + network_neighbor_out_ids: unknown; +}; + +export type RenderSummary = { + [agent_id: string]: AgentTransition; +}; + +// Mutably advances "cur" to "to" based on the lerpval +export function lerpAnimValue( + { current, to }: AnimValue, + lerpVal: number +): [number, number, number] { + if (current) { + return [ + lerp(current[0], to[0], lerpVal), + lerp(current[1], to[1], lerpVal), + lerp(current[2], to[2], lerpVal), + ]; + } else { + return to; + } +} + +/** + * Lerp - Linear Interpolation creates a smooth transiton between two points, x and y + * The lerpval determines the increment left to transition from x to y + */ +export const lerp = (x: number, y: number, lerpVal: number) => + y * lerpVal + x * (1 - lerpVal); diff --git a/apps/sim-core/packages/core/src/components/AgentScene/util/builtinmodels.ts b/apps/sim-core/packages/core/src/components/AgentScene/util/builtinmodels.ts new file mode 100644 index 0000000..c37838e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/util/builtinmodels.ts @@ -0,0 +1,365 @@ +type PolyModel = { + folderPath: string; + resourceUrls: string[]; + slug: string; +}; + +export const BUILTIN_MODELS: { + // rotation as degrees + [id: string]: { rotX?: number; rotY?: number; rotZ?: number }; +} = { + "elm-tree": {}, + "palm-tree": {}, + "spruce-tree": {}, + "train-tracks": {}, + "xmas-tree": {}, + ant: { rotZ: 270 }, + bamboo: {}, + bird: { rotZ: 270 }, + boat: {}, + car: {}, + cat: {}, + conveyor: { rotZ: 90 }, + crane: { rotZ: 90 }, + crate: { rotZ: 45 }, + cybertruck: { rotZ: 45 }, + dolphin: {}, + factory: {}, + fire: {}, + fish: {}, + forklift: {}, + fox: {}, + house: {}, + jet: {}, + locomotive: {}, + missile: { rotZ: 270 }, + pig: {}, + pipe: { rotZ: 270 }, + plane: { rotZ: 270 }, + rabbit: {}, + radar: {}, + satellite: { rotX: 90 }, + silo: {}, + skyscraper: {}, + store: {}, + wheat: {}, + windturbine: {}, +}; + +export const BUILTIN_MODELS_DB: PolyModel[] = [ + { + folderPath: "https://cdn-us1.hash.ai/polys/bamboo/", + slug: "bamboo", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/bamboo/PUSHILIN_bamboo.mtl", + "https://cdn-us1.hash.ai/polys/bamboo/PUSHILIN_bamboo.obj", + "https://cdn-us1.hash.ai/polys/bamboo/PUSHILIN_bamboo.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/spruce-tree/", + slug: "spruce-tree", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/spruce-tree/SpruceTree_BaseColor.png", + "https://cdn-us1.hash.ai/polys/spruce-tree/SpruceTree.mtl", + "https://cdn-us1.hash.ai/polys/spruce-tree/SpruceTree.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/wheat/", + slug: "wheat", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/wheat/FieldOfWheat_BaseColor.png", + "https://cdn-us1.hash.ai/polys/wheat/FieldOfWheat.mtl", + "https://cdn-us1.hash.ai/polys/wheat/FieldOfWheat.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/ant/", + slug: "ant", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/ant/CHAHIN_ANT.mtl", + "https://cdn-us1.hash.ai/polys/ant/CHAHIN_ANT.obj", + "https://cdn-us1.hash.ai/polys/ant/CHAHIN_ANT_TEXTURE.jpg", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/cat/", + slug: "cat", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/cat/Mesh_Cat.mtl", + "https://cdn-us1.hash.ai/polys/cat/Mesh_Cat.obj", + "https://cdn-us1.hash.ai/polys/cat/Tex_Cat.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/plane/", + slug: "plane", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/plane/PUSHILIN_Plane.mtl", + "https://cdn-us1.hash.ai/polys/plane/PUSHILIN_Plane.obj", + "https://cdn-us1.hash.ai/polys/plane/PUSHILIN_PLANE.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/locomotive/", + slug: "locomotive", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/locomotive/1392 Train.mtl", + "https://cdn-us1.hash.ai/polys/locomotive/1392 Train.obj", + "https://cdn-us1.hash.ai/polys/locomotive/1392 Train.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/palm-tree/", + slug: "palm-tree", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/palm-tree/QueenPalmTree_BaseColor.png", + "https://cdn-us1.hash.ai/polys/palm-tree/QueenPalmTree.mtl", + "https://cdn-us1.hash.ai/polys/palm-tree/QueenPalmTree.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/xmas-tree/", + slug: "xmas-tree", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/xmas-tree/materials.mtl", + "https://cdn-us1.hash.ai/polys/xmas-tree/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/pig/", + slug: "pig", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/pig/Mesh_Pig.mtl", + "https://cdn-us1.hash.ai/polys/pig/Mesh_Pig.obj", + "https://cdn-us1.hash.ai/polys/pig/Tex_Pig.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/cybertruck/", + slug: "cybertruck", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/cybertruck/materials.mtl", + "https://cdn-us1.hash.ai/polys/cybertruck/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/house/", + slug: "house", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/house/PUSHILIN_house.mtl", + "https://cdn-us1.hash.ai/polys/house/PUSHILIN_house.obj", + "https://cdn-us1.hash.ai/polys/house/PUSHILIN_house.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/car/", + slug: "car", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/car/1377 Car.mtl", + "https://cdn-us1.hash.ai/polys/car/1377 Car.obj", + "https://cdn-us1.hash.ai/polys/car/1377 Car.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/dolphin/", + slug: "dolphin", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/dolphin/Dolphin_BaseColor.png", + "https://cdn-us1.hash.ai/polys/dolphin/Dolphin.mtl", + "https://cdn-us1.hash.ai/polys/dolphin/Dolphin.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/radar/", + slug: "radar", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/radar/SatelliteDish_BaseColor.png", + "https://cdn-us1.hash.ai/polys/radar/SatelliteDish.mtl", + "https://cdn-us1.hash.ai/polys/radar/SatelliteDish.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/silo/", + slug: "silo", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/silo/CHAHIN_SILO_TEXTURE.jpg", + "https://cdn-us1.hash.ai/polys/silo/CHAHIN_SILO.mtl", + "https://cdn-us1.hash.ai/polys/silo/CHAHIN_SILO.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/bird/", + slug: "bird", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/bird/Flying gull Texture 1.mtl", + "https://cdn-us1.hash.ai/polys/bird/Flying gull Texture 1.obj", + "https://cdn-us1.hash.ai/polys/bird/Gull tex1.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/fire/", + slug: "fire", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/fire/PUSHILIN_campfire.mtl", + "https://cdn-us1.hash.ai/polys/fire/PUSHILIN_campfire.obj", + "https://cdn-us1.hash.ai/polys/fire/PUSHILIN_campfire.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/missile/", + slug: "missile", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/missile/DefaultMaterial_Base_Color.png", + "https://cdn-us1.hash.ai/polys/missile/Missile.mtl", + "https://cdn-us1.hash.ai/polys/missile/Missile.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/pipe/", + slug: "pipe", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/pipe/Pipe.mtl", + "https://cdn-us1.hash.ai/polys/pipe/Pipe.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/skyscraper/", + slug: "skyscraper", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/skyscraper/PUSHILIN_skyscraper.mtl", + "https://cdn-us1.hash.ai/polys/skyscraper/PUSHILIN_skyscraper.obj", + "https://cdn-us1.hash.ai/polys/skyscraper/PUSHILIN_skyscraper.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/elm-tree/", + slug: "elm-tree", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/elm-tree/ElmTree_BaseColor.png", + "https://cdn-us1.hash.ai/polys/elm-tree/ElmTree.mtl", + "https://cdn-us1.hash.ai/polys/elm-tree/ElmTree.OBJ", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/jet/", + slug: "jet", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/jet/1397 Jet.mtl", + "https://cdn-us1.hash.ai/polys/jet/1397 Jet.obj", + "https://cdn-us1.hash.ai/polys/jet/1397 Jet.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/factory/", + slug: "factory", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/factory/PUSHILIN_factory.mtl", + "https://cdn-us1.hash.ai/polys/factory/PUSHILIN_factory.obj", + "https://cdn-us1.hash.ai/polys/factory/PUSHILIN_factory.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/satellite/", + slug: "satellite", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/satellite/SpaceProbe_BaseColor.png", + "https://cdn-us1.hash.ai/polys/satellite/SpaceProbe.mtl", + "https://cdn-us1.hash.ai/polys/satellite/SpaceProbe.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/fox/", + slug: "fox", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/fox/Fox_BaseColor.png", + "https://cdn-us1.hash.ai/polys/fox/Fox.mtl", + "https://cdn-us1.hash.ai/polys/fox/Fox.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/train-tracks/", + slug: "train-tracks", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/train-tracks/Bullet Train Texture.png", + "https://cdn-us1.hash.ai/polys/train-tracks/Rails.mtl", + "https://cdn-us1.hash.ai/polys/train-tracks/Rails.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/forklift/", + slug: "forklift", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/forklift/materials.mtl", + "https://cdn-us1.hash.ai/polys/forklift/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/rabbit/", + slug: "rabbit", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/rabbit/Mesh_Rabbit.mtl", + "https://cdn-us1.hash.ai/polys/rabbit/Mesh_Rabbit.obj", + "https://cdn-us1.hash.ai/polys/rabbit/Tex_Rabbit.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/conveyor/", + slug: "conveyor", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/conveyor/materials.mtl", + "https://cdn-us1.hash.ai/polys/conveyor/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/crane/", + slug: "crane", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/crane/materials.mtl", + "https://cdn-us1.hash.ai/polys/crane/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/crate/", + slug: "crate", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/crate/materials.mtl", + "https://cdn-us1.hash.ai/polys/crate/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/fish/", + slug: "fish", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/fish/Mesh_Fish.mtl", + "https://cdn-us1.hash.ai/polys/fish/Mesh_Fish.obj", + "https://cdn-us1.hash.ai/polys/fish/Tex_Salmon.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/store/", + slug: "store", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/store/materials.mtl", + "https://cdn-us1.hash.ai/polys/store/model.obj", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/boat/", + slug: "boat", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/boat/Tugboat.mtl", + "https://cdn-us1.hash.ai/polys/boat/Tugboat.obj", + "https://cdn-us1.hash.ai/polys/boat/Tugboat_BaseColor.png", + ], + }, + { + folderPath: "https://cdn-us1.hash.ai/polys/windturbine/", + slug: "windturbine", + resourceUrls: [ + "https://cdn-us1.hash.ai/polys/windturbine/windturbine.mtl", + "https://cdn-us1.hash.ai/polys/windturbine/windturbine.obj", + ], + }, +]; diff --git a/apps/sim-core/packages/core/src/components/AgentScene/util/geometry-loader.ts b/apps/sim-core/packages/core/src/components/AgentScene/util/geometry-loader.ts new file mode 100644 index 0000000..818bc57 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/AgentScene/util/geometry-loader.ts @@ -0,0 +1,234 @@ +import * as THREE from "three"; +import { BufferGeometryUtils } from "three/examples/jsm/utils/BufferGeometryUtils"; +import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader"; +import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader"; + +import { BUILTIN_MODELS, BUILTIN_MODELS_DB } from "./builtinmodels"; + +export type RawGeometry = [THREE.BufferGeometry, THREE.Material]; + +export const loadGeometryMesh = async ( + userMeshName: string, + num: number +): Promise => { + switch (userMeshName) { + case "box": + return geoHelper("BoxBufferGeometry", num, [1, 1, 1]); + case "cone": + const [geo, mat] = geoHelper("ConeBufferGeometry", num, [0.5, 1, 30]); + // Our cones point in the forward direction + // Cones normally point up + // We need to rotate them so they point the correct direction + geo.rotateX(Math.PI); + geo.rotateY(Math.PI / 2); + return [geo, mat]; + case "flatplane": + const [geoPlane, matPlane] = geoHelper("PlaneBufferGeometry", num, [ + 1, + 1, + ]); + geoPlane.translate(0, 0, -0.5); + return [geoPlane, matPlane]; + case "cylinder": + return geoHelper("CylinderBufferGeometry", num, [0.5, 0.5]); + case "dodecahedron": + return geoHelper("DodecahedronBufferGeometry", num, [0.5]); + case "icosahedron": + return geoHelper("IcosahedronBufferGeometry", num, [0.5]); + case "octahedron": + return geoHelper("OctahedronBufferGeometry", num, [0.5]); + case "sphere": + return geoHelper("SphereBufferGeometry", num, [0.5]); + case "tetrahedron": + return geoHelper("TetrahedronBufferGeometry", num, [0.5]); + case "torus": + return geoHelper("TorusBufferGeometry", num, [0.3, 0.2, 10, 10]); + case "torusknot": + return geoHelper("TorusKnotBufferGeometry", num, [0.3, 0.2, 10, 10]); + case "pickedAgent": + return pickedMesh(); + default: + try { + const model = await polyLoader(userMeshName); + return model; + } catch (err) { + // Fail through and produce a box + return geoHelper("BoxBufferGeometry", num, [1, 1, 1]); + } + } +}; + +type SupportedShapes = + | "BoxBufferGeometry" + | "ConeBufferGeometry" + | "PlaneBufferGeometry" + | "CylinderBufferGeometry" + | "DodecahedronBufferGeometry" + | "IcosahedronBufferGeometry" + | "OctahedronBufferGeometry" + | "SphereBufferGeometry" + | "TetrahedronBufferGeometry" + | "TorusBufferGeometry" + | "TorusKnotBufferGeometry"; + +/** + * Create a new InstanceMesh from a geometry name and constructor parameters + */ +const geoHelper = ( + geoType: SupportedShapes, + numMeshes: number, + args: number[] +): RawGeometry => { + const geometry = new THREE[geoType](...args); + geometry.computeVertexNormals(); + + const colors = new Float32Array(numMeshes * 3).map(() => 0); + geometry.setAttribute("color", new THREE.InstancedBufferAttribute(colors, 3)); + const material = new THREE.MeshPhongMaterial({ + vertexColors: true, + shininess: 0.1, + reflectivity: 0.1, + }); + return [geometry, material]; +}; + +const pickedMesh = (): RawGeometry => { + const modelGeometry = new THREE.Geometry(); + + // The hover diamond + const coneGeometry = new THREE.ConeGeometry(); + coneGeometry.translate(0, 0, 0); + coneGeometry.scale(0.2, 0.2, 0.2); + coneGeometry.rotateX(Math.PI); + coneGeometry.translate(0, 0.8, 0); + + // The bounding box + const boxGeometry = new THREE.BoxGeometry(); + boxGeometry.scale(1.05, 1.05, 1.05); + + // Merge them + modelGeometry.merge(coneGeometry); + modelGeometry.merge(boxGeometry); + const bufGeometry = new THREE.BufferGeometry().fromGeometry(modelGeometry); + + // A simple bounding box + const material = new THREE.MeshStandardMaterial({ + color: "white", + wireframe: true, + }); + + // We need to align the coordinate systems of the normal geometry and the models + bufGeometry.rotateX(Math.PI / 2); + return [bufGeometry, material]; +}; + +/** + * Fetch a model from the API and return its geoemtry and materials + */ +export const polyLoader = async (meshName: string): Promise => { + // Check for a built-in and any specific rotation information + const builtin = BUILTIN_MODELS[meshName]; + if (!builtin) { + throw new Error(`Unrecognised meshName ${meshName}`); + } + + const { rotX, rotY, rotZ } = builtin; + + const { folderPath, objectUrl, materialUrl } = await fetchPolyFromBuiltinDb( + meshName + ); + + // Three has built-in loaders that know how to fetch from URLs, but the most reliable + // method is to just fetch the texts manually and have the loaders parse them + const [objText, mtlText] = await Promise.all([ + fetch(objectUrl).then((rawObj) => rawObj.text()), + fetch(materialUrl).then((rawMtl) => rawMtl.text()), + ]); + + const materialLoader = new MTLLoader(); + + // Need to set this, otherwise meshes will end up black + materialLoader.setMaterialOptions({ + ignoreZeroRGBs: true, + }); + + const materials = materialLoader.parse(mtlText, folderPath); + + const objLoader = new OBJLoader(); + objLoader.setMaterials(materials); + + const mergedGeometries: THREE.BufferGeometry[] = []; + const mergedMaterials: THREE.Material[] = []; + + objLoader.parse(objText).traverse((obj) => { + if (obj instanceof THREE.Mesh) { + mergedGeometries.push(obj.geometry); + mergedMaterials.push(obj.material); + } + }); + + mergedMaterials.concat(Object.values(materials.materials)); + const geometry = BufferGeometryUtils.mergeBufferGeometries( + mergedGeometries, + true + ); + + // Yes, it's deprecated, but it's the only way to get material merging to work + const material = new THREE.MultiMaterial(Object.values(mergedMaterials)); + material.vertexColors = true; + + // Resize the mesh to fit within a single cube + const boundingBox = new THREE.Box3(); + const sizingMesh = new THREE.Mesh(geometry, material); + boundingBox.setFromObject(sizingMesh); + const size = boundingBox.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + geometry.scale(1 / maxDim, 1 / maxDim, 1 / maxDim); + + // Move the mesh down so it sits at the floor of the bounding box + geometry.translate(0, -(1 - size.y / maxDim) / 2, 0); + + // We need to align the coordinate systems of the normal geometry and the models + geometry.rotateX(Math.PI / 2); + geometry.computeBoundingBox(); + geometry.computeVertexNormals(); + + // Apply any builtin-specific rotation + geometry.rotateX((rotX ?? 0) * (Math.PI / 180)); + geometry.rotateY((rotY ?? 0) * (Math.PI / 180)); + geometry.rotateZ((rotZ ?? 0) * (Math.PI / 180)); + + return [geometry, material]; +}; + +const fetchPolyFromBuiltinDb = async (slug: string) => { + const { folderPath, resourceUrls } = BUILTIN_MODELS_DB.find((model) => { + return model.slug === slug; + }) || { folderPath: null, resourceUrls: [] }; + + if (!folderPath) { + throw new Error("No folderPath found for built-in model " + slug); + } + + const objectUrl = resourceUrls.find((url) => + url.toLowerCase().endsWith(".obj") + ); + + if (!objectUrl) { + throw new Error("No .obj file found for built-in model " + slug); + } + + const materialUrl = resourceUrls.find((url) => + url.toLowerCase().endsWith(".mtl") + ); + + if (!materialUrl) { + throw new Error("No .mtl file found for built-in model " + slug); + } + + return { + folderPath, + objectUrl, + materialUrl, + }; +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.scss b/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.scss new file mode 100644 index 0000000..516b3d0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.scss @@ -0,0 +1,246 @@ +.AnalysisViewer { + background: var(--theme-dark); + position: relative; + overflow: auto; + display: flex; + flex-direction: column; + padding: 8px 20px 20px; + /** + * inherits `flex-grow: 1;` from + * `packages/core/src/components/HashCore/Viewer/HashCoreViewer.css` + */ + + .empty { + padding: 1em; + } + + .react-tabs__tab-panel--selected { + min-height: 100%; + } +} + +.AnalysisViewer__Container { + padding: 1.5rem; + background: var(--theme-dark); + max-width: 100%; + max-height: 100%; + box-sizing: border-box; + height: 100%; + display: flex; + flex-direction: column; +} + +.AnalysisViewer__CenteredDiv { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + text-align: center; + background-color: var(--theme-darkest); + user-select: none; + + p { + max-width: 60%; + line-height: 1.3; + position: relative; + } +} + +.AnalysisViewer__CenteredDiv { + flex-direction: column; + font-size: 1rem; +} + +.AnalysisViewer__CenteredDiv > span { + margin-bottom: 1rem; +} + +.AnalysisViewer__CenteredDiv > .AnalysisViewer__MetricsHelp { + flex: 0; + margin-top: 1rem; +} + +.AnalysisViewer__CenteredDiv__Pre { + text-align: left; + font-size: 1.5rem; + background: var(--theme-black); + padding: 2rem; + width: 75%; + overflow: auto; +} + +.AnalysisViewer__Header { + display: flex; + flex-direction: row; + justify-items: left; + padding: 0; + align-items: center; + user-select: none; + overflow: hidden; + font-size: 18px; + line-height: 3; + + &, + > * { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.AnalysisViewer__Header__Sub { + color: rgba(255, 255, 255, 0.5); +} + +.AnalysisViewer__Tabs { + height: 100%; +} + +.AnalysisViewer__TabContainer { + display: flex; + width: 100%; + flex-direction: row; + justify-content: space-between; + border-bottom: 1px solid transparent; + position: relative; + + &:after { + content: ""; + display: block; + height: 1px; + background-color: var(--theme-border); + left: 0; + right: 0; + width: 100%; + bottom: -1px; + position: absolute; + pointer-events: none; + z-index: calc(var(--popover-z) + 2); + } + + /** + * These styles are nested in here for specificity issues + */ + .AnalysisViewer__TabContainer__TabList { + background: none; + text-transform: uppercase; + font-weight: bold; + font-size: 12px; + margin-bottom: -1px; + } + + .AnalysisViewer__TabContainer__Tab { + background: none; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + border: 1px solid transparent; + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + + &:not(.react-tabs__tab--selected):hover { + border-color: var(--theme-border); + z-index: calc(var(--popover-z) + 1); + } + } +} + +.AnalysisViewer__TabContainer__Tab--disabled { + cursor: default !important; + + svg { + opacity: 0.5; + } + + &:hover { + background-color: var(--theme-dark) !important; + } +} + +.AnalysisViewer__TabPanel { + display: block !important; + overflow: auto !important; + padding-right: 1.5rem; +} + +.AnalysisViewer__LoggedOut.AnalysisViewer__TabPanel__Plots--nodata { + display: flex !important; + align-items: center !important; + justify-content: center; + color: var(--theme-grey-alt); +} + +.AnalysisViewer__TabPanel--nodata { + display: flex !important; /* TODO: find a way to avoid using important here */ + align-items: center !important; + justify-content: center !important; + flex-direction: column; + color: var(--theme-grey-alt); + height: 100%; + text-align: center !important; +} + +.AnalysisViewer__NoData { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + p { + margin-top: 0; + } +} + +.AnalysisViewer__MetricsHelp { + fill: white; + display: flex; + justify-content: center; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + margin-bottom: 3rem; + line-height: 1.5; + color: var(--theme-grey-alt); + + a { + text-decoration: none; + } +} + +.AnalysisViewer__MetricsHelp__IconText { + display: flex; + align-items: center; + justify-content: center; + margin-right: 6px; + font-weight: bold; + + .Icon { + fill: currentColor; + flex-shrink: 0; + margin-right: 6px; + } +} + +.AnalysisViewerSplitterController { + --avsc-viewer-min-width: 559px; + --avsc-total-min-width: calc( + var(--avsc-viewer-min-width) + var(--avsc-analysis-width) + ); + + min-width: var(--avsc-total-min-width); + + .HashCoreViewer > .splitter-layout { + > .layout-pane-primary { + min-width: var(--avsc-viewer-min-width); + } + + > .layout-pane:not(.layout-pane-primary) { + max-width: var(--avsc-analysis-width); + } + } +} diff --git a/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.tsx b/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.tsx new file mode 100644 index 0000000..39cae67 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewer.tsx @@ -0,0 +1,300 @@ +import React, { FC, useCallback, useEffect, useState } from "react"; +import { unstable_batchedUpdates } from "react-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; +import { useModal } from "react-modal-hook"; +import classNames from "classnames"; +import { sum } from "lodash"; + +import { AnalysisProps, Plot } from "./types"; +import { AnalysisViewerActionButtons } from "./AnalysisViewerActionButtons"; +import { HelpParagraph } from "./HelpParagraph"; +import { ModalOutputMetrics } from "../Modal/Analysis/ModalOutputMetrics"; +import { ModalPlots } from "../Modal/Analysis/ModalPlots"; +import { OutputMetricsTab } from "./OutputMetricsTab"; +import { PlotsTab } from "./PlotsTab"; +import { Scope, useScope } from "../../features/scopes"; +import { getHelpForSyntaxError } from "./utils"; +import { + onDuplicateMetric, + onOutputMetricsModalDelete, + onOutputMetricsModalSave, + onPlotsModalDelete, + onPlotsModalSave, +} from "./modals"; +import { selectAnalysisMode } from "../../features/simulator/simulate/selectors"; +import { selectEmbedded } from "../../features/viewer/selectors"; +import { useAnalysisSrcForCurrentActivityItem } from "../../hooks/useAnalysisSrcForCurrentActivityItem"; +import { useParseAnalysis } from "../../hooks/useParseAnalysis"; +import { useResizeObserver } from "../../hooks/useResizeObserver/useResizeObserver"; +import { useSimulatorSelector } from "../../features/simulator/context"; + +import "./AnalysisViewer.scss"; + +export const AnalysisViewer: FC = ({ currentStep }) => { + const dispatch = useDispatch(); + const analysisMode = useSimulatorSelector(selectAnalysisMode); + const embedded = useSelector(selectEmbedded); + const canEdit = useScope(Scope.edit); + + const { + analysis: analysisString, + readonly: analysisReadOnly, + } = useAnalysisSrcForCurrentActivityItem(); + const [analysisState, setAnalysis] = useParseAnalysis(analysisString); + + // @todo remove this any + const { analysis, error }: any = analysisState; + + // TODO: discuss if we also need the useCancellableDebounce trick here + + const outputs = (analysis && analysis.outputs) || {}; + const metricKeys = Object.keys(outputs); + const analysisOutputMetricsDataAvailable = metricKeys.length > 0; + const analysisPlotsDataAvailable = analysis?.plots?.length > 0; + const combinedHeightOfAllPlots = !analysisPlotsDataAvailable + ? 0 + : sum( + analysis.plots.map((plot: Plot) => + parseInt(plot.layout?.height?.replace?.("%", "") ?? 0, 10) + ) + ); + + // @todo collapse state + const [_currentTab, setCurrentTab] = useState(1); + const [hasTouchedCurrentTab, setHasTouchedCurrentTab] = useState(false); + + const currentTab = hasTouchedCurrentTab + ? _currentTab + : !embedded && analysisPlotsDataAvailable + ? 1 + : 0; + + const onOutputMetricsModalSaveHandler = useCallback( + (data: any, previousKey?: string) => + onOutputMetricsModalSave({ + dispatch, + setAnalysis, + analysisString, + analysis, + data, + previousKey, + }), + [dispatch, setAnalysis, analysis, analysisString] + ); + + const onOutputMetricsModalDeleteHandler = (keyToDelete: string) => + onOutputMetricsModalDelete({ + dispatch, + setAnalysis, + analysisString, + analysis, + keyToDelete, + }); + + const onDuplicateMetricHandler = (metricKey: string) => + onDuplicateMetric({ + analysis, + dispatch, + setAnalysis, + analysisString, + metricKey, + }); + + const onPlotsModalSaveHandler = useCallback( + (data, plotIndex) => + onPlotsModalSave({ + data, + plotIndex, + analysis, + analysisString, + dispatch, + setAnalysis, + }), + [analysis, analysisString, dispatch] + ); + + const onPlotsModalDeleteHandler = (indexToDelete: number) => + onPlotsModalDelete({ + indexToDelete, + dispatch, + setAnalysis, + analysisString, + analysis, + }); + + const [showOutputMetricsModal, hideOutputMetricsModal] = useModal( + () => ( + + ), + [metricKeys, onOutputMetricsModalSaveHandler] + ); + + const [showPlotsModal, hidePlotsModal] = useModal( + () => ( + + ), + [outputs, onPlotsModalSaveHandler, combinedHeightOfAllPlots] + ); + + const tabContainerWidthObserver = useResizeObserver( + ({ width }) => { + document.documentElement.style.setProperty( + "--analysis-tab-container-width", + `${Math.floor(width)}px` + ); + }, + { onObserve: null } + ); + + useEffect(() => { + if (analysisOutputMetricsDataAvailable && currentTab === 0 && !embedded) { + const viewerSecondaryPane = document.querySelector( + ".HashCoreSection-splitter > .splitter-layout > .layout-pane:not(.layout-pane-primary)" + ); + + const analysisSecondaryPane = viewerSecondaryPane?.querySelector( + ".HashCoreViewer > .splitter-layout > .layout-pane:not(.layout-pane-primary)" + ); + + viewerSecondaryPane?.classList.add("AnalysisViewerSplitterController"); + viewerSecondaryPane?.style.setProperty( + "--avsc-analysis-width", + `${analysisSecondaryPane?.getBoundingClientRect().width ?? 0}px` + ); + + return () => { + viewerSecondaryPane?.classList.remove( + "AnalysisViewerSplitterController" + ); + viewerSecondaryPane?.style.removeProperty("--avsc-analysis-width"); + }; + } + }, [analysisOutputMetricsDataAvailable, currentTab, embedded]); + + if (!analysis) { + return ( +
+ + Error + + We could not parse the contents of analysis.json + {error && ( +
+            {getHelpForSyntaxError(error, analysisString)}
+          
+ )} + +
+ ); + } + + const plotsTab = ( + + ); + + if (!canEdit) { + return ( +
+
+
+ {plotsTab} +
+
+
+ ); + } + + return ( +
+ { + unstable_batchedUpdates(() => { + setHasTouchedCurrentTab(true); + setCurrentTab(tabIndex); + }); + }} + > +
+ + + Metrics + + + Plots + + + {analysisReadOnly ? null : ( + + )} +
+ + + + + {plotsTab} + +
+
+ ); +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewerActionButtons.tsx b/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewerActionButtons.tsx new file mode 100644 index 0000000..517b2a2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/AnalysisViewerActionButtons.tsx @@ -0,0 +1,104 @@ +import React, { + FC, + MouseEventHandler, + ReactElement, + ReactFragment, +} from "react"; +import classNames from "classnames"; + +import { AnalysisViewerActionButtonsProps } from "./types"; +import { IconAddDatapoint } from "../Icon/AddDatapoint"; +import { IconCreatePlot } from "../Icon/CreatePlot"; +import { SimpleTooltip } from "../SimpleTooltip"; + +import "./TabListActionButtons.scss"; + +type ListItemProps = { + icon: ReactElement; + tooltipContent: ReactFragment; + onClick?: MouseEventHandler; + listIndex: number; + disabled?: boolean; +}; + +const ListItem: FC = ({ + icon, + tooltipContent, + onClick, + listIndex, + disabled, +}) => ( +
  • + {icon} + + {tooltipContent} + +
  • +); + +export const AnalysisViewerActionButtons: FC = ({ + canCreateNewPlot = false, + showPlotsModal, + showOutputMetricsModal, + canEdit, +}) => { + if (!canEdit) { + throw new Error( + "Should not be rendering analysis viewer action buttons in a read only context" + ); + } + + const plotTooltip = ( + <> +

    Create new Plot

    +

    + {canCreateNewPlot ? ( + <>Use output metrics to create new Plots + ) : ( + <> + You can't create a new plot without defining at least one metric + first + + )} +

    + + ); + return ( +
      + } + tooltipContent={ + <> +

      Create New Output Metric

      +

      + You’ll need to define one or more output metrics in order to + create a plot +

      + + } + listIndex={1} + /> + {}} + icon={} + tooltipContent={plotTooltip} + disabled={!canCreateNewPlot} + listIndex={0} + /> +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.scss b/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.scss new file mode 100644 index 0000000..cde7da8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.scss @@ -0,0 +1,27 @@ +.ButtonCallToAction { + margin: 1.5rem auto; + border: 1px solid var(--theme-border); + border-radius: var(--item-border-radius); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem 3rem 1rem 2rem; + font-size: 1rem; + fill: white; + text-align: left; + color: white; + + div { + display: flex; + flex-direction: column; + } + + > div > strong { + font-size: 1.25rem; + margin-bottom: 0.25rem; + } + + > .Icon { + margin-right: 1.5rem; + } +} diff --git a/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.spec.tsx b/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.spec.tsx new file mode 100644 index 0000000..a942a5d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.spec.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { ModalProvider } from "react-modal-hook"; + +import { ButtonCallToAction } from "./ButtonCallToAction"; +import { ErrorBoundary } from "../ErrorBoundary"; +import { mockProject } from "../../features/project/mocks"; +import { setProjectWithMeta } from "../../features/actions"; +import { store } from "../../features/store"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + + store.dispatch(setProjectWithMeta(mockProject)); + + ReactDOM.render( + + + + +

    Testing

    +
    +
    +
    +
    , + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.tsx b/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.tsx new file mode 100644 index 0000000..6e605ae --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/ButtonCallToAction.tsx @@ -0,0 +1,14 @@ +import React, { FC } from "react"; + +import { ButtonCallToActionProps } from "./types"; + +import "./ButtonCallToAction.scss"; + +export const ButtonCallToAction: FC = ({ + children, + onClick, +}) => ( + +); diff --git a/apps/sim-core/packages/core/src/components/Analysis/HelpParagraph.tsx b/apps/sim-core/packages/core/src/components/Analysis/HelpParagraph.tsx new file mode 100644 index 0000000..0837756 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/HelpParagraph.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; + +import { IconHelpCircleOutline } from "../Icon/HelpCircleOutline"; + +type HelpParagraphProps = { + text: string; +}; + +export const HelpParagraph: FC = ({ text }) => ( +
    +); diff --git a/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.scss b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.scss new file mode 100644 index 0000000..24f3301 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.scss @@ -0,0 +1,108 @@ +.AnalysisViewer__OutputMetricsGridContainer { + overflow: hidden; + border-radius: var(--item-border-radius); + border: 1px solid var(--theme-border); + margin-top: 4rem; + margin-bottom: 1.5rem; +} + +.AnalysisViewer__OutputMetricsGrid { + display: grid; + margin: 0; + padding: 0; + grid-template-columns: repeat(auto-fit, minmax(30rem, 1fr)); +} + +.AnalysisViewer__OutputMetricsGrid__ListItem { + background: var(--theme-dark); + border: 1px solid var(--theme-border); + padding: 1rem 2rem 1rem 2rem; + display: flex; + align-items: center; + justify-content: space-between; + color: #ffffff; + flex-basis: 25%; + cursor: pointer; + min-height: 5rem; + fill: white; +} + +.AnalysisViewer__OutputMetricsGrid__ListItem--readonly { + cursor: default; +} + +.AnalysisViewer__OutputMetricsGrid__BigNumber { + align-self: start; + color: #66686c; + flex: 1; + font-size: 2rem; + font-weight: bold; + height: 40px; + margin-right: 1em; +} + +.AnalysisViewer__OutputMetricsGrid__TitleContainer { + display: flex; + justify-content: space-between; + fill: white; + margin: 0; +} + +.AnalysisViewer__OutputMetricsGrid__Title { + font-weight: bold; + margin: 0 0 0.7rem 0; +} + +.AnalysisViewer__OutputMetricsGrid__TextContainer { + display: flex; + flex-direction: column; + flex: 40; + align-self: start; + padding-top: 0.3rem; +} + +.AnalysisViewer__OutputMetricsGrid__Operation { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.AnalysisViewer__OutputMetricsGrid__Operation__Op { + margin-left: 0; +} + +.AnalysisViewer__OutputMetricsGrid__Operation__Op, +.AnalysisViewer__OutputMetricsGrid__Operation__Field, +.AnalysisViewer__OutputMetricsGrid__Operation__Comparison, +.AnalysisViewer__OutputMetricsGrid__Operation__Value { + margin: 0 0.2rem 0 0.2rem; + font-size: 1rem; +} + +.AnalysisViewer__OutputMetricsGrid__Operation__Field, +.AnalysisViewer__OutputMetricsGrid__Operation__Value { + border-radius: 8px; + background: var(--background-grey); + padding: 0.3rem; +} + +.AnalysisViewer__OutputMetricsGrid__Operation--nooperations { + opacity: 0.5; + font-style: italic; +} + +.AnalysisViewer__OutputMetricsGrid__ListItem__AddNewMetric { + display: flex; + align-items: center; + justify-content: center; + background: var(--theme-black); + + .AnalysisViewer__OutputMetricsGrid__Title, + .AnalysisViewer__OutputMetricsGrid__TextContainer { + margin: 0; + padding: 0; + align-self: center; + } +} diff --git a/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.spec.tsx b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.spec.tsx new file mode 100644 index 0000000..746325a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.spec.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { ModalProvider } from "react-modal-hook"; + +import { ComparisonTypes, Operation, OperationTypes } from "./types"; +import { ErrorBoundary } from "../ErrorBoundary"; +import { OutputMetricsGrid } from "./OutputMetricsGrid"; +import { mockProject } from "../../features/project/mocks"; +import { setProjectWithMeta } from "../../features/actions"; +import { store } from "../../features/store"; + +const noop = () => {}; +const operations: Operation[] = [ + { + op: OperationTypes.filter, + field: "age", + comparison: ComparisonTypes.eq, + value: 15, + }, + { op: OperationTypes.count }, +]; +const metrics = { metricName: operations }; + +it("renders without crashing", () => { + const div = document.createElement("div"); + + store.dispatch(setProjectWithMeta(mockProject)); + + ReactDOM.render( + + + + + + + , + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.tsx b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.tsx new file mode 100644 index 0000000..381c8f6 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsGrid.tsx @@ -0,0 +1,195 @@ +import React, { FC, useReducer } from "react"; +import { useModal } from "react-modal-hook"; +import classNames from "classnames"; +import { omit } from "lodash"; + +import { IconAddDatapoint } from "../Icon/AddDatapoint"; +import { IconContentDuplicate } from "../Icon/ContentDuplicate"; +import { + ModalOutputMetrics, + defaultNewOperation, +} from "../Modal/Analysis/ModalOutputMetrics"; +import { Operation, OutputMetricsGridProps } from "./types"; +import { getHumanReadableComparison } from "./utils"; + +import "./OutputMetricsGrid.scss"; + +const reducer = (state: any, action: any) => { + switch (action.type) { + case "CREATE": + return { + ...state, + ...omit(action, "type"), + isCreate: true, + }; + case "EDIT": + return { + ...state, + ...omit(action, "type"), + isCreate: false, + }; + default: + throw new Error( + `OutputMetricsGrid: reducer received an unknown action: "${action.type}"` + ); + } +}; + +export const OutputMetricsGrid: FC = ({ + metrics, + readonly, + onOutputMetricsModalSave, + onOutputMetricsModalDelete = () => {}, + onDuplicateMetric = () => {}, +}) => { + const [state, modalDispatcher] = useReducer(reducer, { + currentOperations: [], + currentMetricKey: "", + isCreate: false, + existingMetricKeys: [], + }); + + const [showOutputMetricsModal, hideOutputMetricsModal] = useModal( + () => ( + + ), + [state, onOutputMetricsModalSave, onOutputMetricsModalDelete] + ); + + if (!metrics) { + return null; + } + const metricKeys = Object.keys(metrics); + if (metricKeys.length === 0) { + return null; + } + + const createNewOutputMetric = () => { + modalDispatcher({ + type: "CREATE", + currentOperations: [defaultNewOperation], + currentMetricKey: `MetricName${metricKeys.length + 1}`, + existingMetricKeys: metricKeys, + }); + showOutputMetricsModal(); + }; + + const editOutputMetric = (ops: Operation[], metricKey: string) => { + modalDispatcher({ + type: "EDIT", + currentOperations: ops, + currentMetricKey: metricKey, + existingMetricKeys: metricKeys, + }); + showOutputMetricsModal(); + }; + + return ( +
    +
      + {metricKeys.map((metricKey, key) => ( +
    • { + evt.preventDefault(); + if (!readonly && Array.isArray(metrics[metricKey])) { + editOutputMetric(metrics[metricKey], metricKey); + } + }} + > + + {key} + +
      +
      +

      + {metricKey} +

      + {readonly || !Array.isArray(metrics[metricKey]) ? null : ( +
      { + ev.preventDefault(); + ev.stopPropagation(); + onDuplicateMetric(metricKey); + }} + > + +
      + )} +
      + {metrics[metricKey].length === 0 ? ( + + (No operations) + + ) : !Array.isArray(metrics[metricKey]) ? ( + + Error: The operation chain must be an array, but you provided + "{typeof metrics[metricKey]}". + + ) : ( + metrics[metricKey].map((op, index: number) => ( + + + {op.op} + + {op.field ? ( +
      +                        {op.field}
      +                      
      + ) : null}{" "} + {op.comparison ? ( + + {getHumanReadableComparison(op.comparison)} + + ) : null}{" "} + {op.value || op.value === 0 || op.value === false ? ( +
      +                        {JSON.stringify(op.value)}
      +                      
      + ) : null} +
      + )) + )} +
      +
    • + ))} + {readonly ? null : ( +
    • +
      + + + +
      +

      + Add new metric +

      +
      +
      +
    • + )} +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsTab.tsx b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsTab.tsx new file mode 100644 index 0000000..700ceff --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/OutputMetricsTab.tsx @@ -0,0 +1,51 @@ +import React, { FC } from "react"; + +import { AnalysisViewerOutputMetricsTabProps } from "./types"; +import { ButtonCallToAction } from "./ButtonCallToAction"; +import { HelpParagraph } from "./HelpParagraph"; +import { IconAddDatapoint } from "../Icon/AddDatapoint"; +import { OutputMetricsGrid } from "./OutputMetricsGrid"; + +export const OutputMetricsTab: FC = ({ + analysisOutputMetricsDataAvailable, + showOutputMetricsModal, + analysis, + onOutputMetricsModalSaveHandler, + onOutputMetricsModalDeleteHandler, + onDuplicateMetricHandler, + readonly, +}) => + !analysisOutputMetricsDataAvailable ? ( +
    +
    + No output metrics have been defined + {readonly ? null : ( + <> +

    + You’ll need to define one or more output metrics in order to create + a plot +

    + + +
    + Define new metric + Define your first metric +
    +
    + + )} +
    + +
    + ) : ( + <> + + + + ); diff --git a/apps/sim-core/packages/core/src/components/Analysis/PlotsTab.tsx b/apps/sim-core/packages/core/src/components/Analysis/PlotsTab.tsx new file mode 100644 index 0000000..dc950d9 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/PlotsTab.tsx @@ -0,0 +1,66 @@ +import React, { FC } from "react"; +import { useSelector } from "react-redux"; + +import { AnalysisViewerPlotsTabProps } from "./types"; +import { ButtonCallToAction } from "./ButtonCallToAction"; +import { HelpParagraph } from "./HelpParagraph"; +import { IconCreatePlot } from "../Icon/CreatePlot"; +import { PlotViewer } from "../PlotViewer/PlotViewer"; +import { Scope, useScopes } from "../../features/scopes"; +import { selectEmbedded } from "../../features/viewer/selectors"; + +export const PlotsTab: FC = ({ + analysisPlotsDataAvailable, + currentStep, + outputs, + onPlotsModalSaveHandler, + onPlotsModalDeleteHandler, + analysisOutputMetricsDataAvailable, + showPlotsModal, + analysisMode, + readonly, +}) => { + const { canEdit, canLogin } = useScopes(Scope.edit, Scope.login); + const embedded = useSelector(selectEmbedded); + + if (!analysisMode) { + return embedded ? null : ( +
    +

    Choose an item from the activity sidebar to analyze

    +
    + ); + } + return analysisPlotsDataAvailable ? ( + + ) : ( +
    +
    + You haven't yet created any plots + {!canLogin && !canEdit ? null : !analysisOutputMetricsDataAvailable ? ( +

    + You’ll need to {!canEdit && "sign in and "} + define at least one output metric to create a plot +

    + ) : ( + <> +

    Click below to create your first visualisation

    + + +
    + Create new plot + Add your first plot +
    +
    + + )} +
    + +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/TabListActionButtons.scss b/apps/sim-core/packages/core/src/components/Analysis/TabListActionButtons.scss new file mode 100644 index 0000000..a5579ee --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/TabListActionButtons.scss @@ -0,0 +1,36 @@ +.AnalysisViewer__ActionButtons__Tooltip { + max-width: calc( + (var(--analysis-tab-container-width) - 10px) - + var(--AnalysisViewer__ActionButtons__Tooltip--index) * 38px + ); + min-width: 0; + --clip-y-below: -50px; +} + +.AnalysisViewer__ActionButtons { + // To ensured shadows are visible + overflow: visible !important; + + // Double class name for specificity + .AnalysisViewer__TabContainer__Tab.AnalysisViewer__TabContainer__Tab { + width: 38px; + --left-clip-x: -50px; + --right-clip-x: 50px; + --bottom-clip-y: calc(100%); + + clip-path: polygon( + var(--left-clip-x) -50px, + calc(100% + var(--right-clip-x)) -50px, + calc(100% + var(--right-clip-x)) var(--bottom-clip-y), + var(--left-clip-x) var(--bottom-clip-y) + ); + + &:hover { + box-shadow: var(--popover-shadow); + } + + &:not(.AnalysisViewer__TabContainer__Tab--disabled):hover { + background-color: black; + } + } +} diff --git a/apps/sim-core/packages/core/src/components/Analysis/modals.test.ts b/apps/sim-core/packages/core/src/components/Analysis/modals.test.ts new file mode 100644 index 0000000..a0e6ad2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/modals.test.ts @@ -0,0 +1,571 @@ +import { + ChartTypes, + ComparisonTypes, + Operation, + OperationTypes, +} from "./types"; +import { analysisFileId, stringifyAnalysis } from "../../features/files/utils"; +import { + onDuplicateMetric, + onOutputMetricsModalDelete, + onOutputMetricsModalSave, + onPlotsModalDelete, + onPlotsModalSave, +} from "./modals"; + +const operations: Operation[] = [ + { + op: OperationTypes.filter, + field: "field1", + comparison: ComparisonTypes.eq, + value: "value", + }, +]; + +const baseWriteToFileTests = (input: any, newValues: any) => { + expect(input.dispatch).toHaveBeenCalledTimes(1); + expect(input.dispatch).toHaveBeenCalledWith({ + type: "files/updateFile", + payload: { + id: analysisFileId, + contents: stringifyAnalysis(newValues), + }, + }); + expect(input.setAnalysis).toHaveBeenCalledTimes(1); + expect(input.setAnalysis).toHaveBeenCalledWith({ + lastAnalysisString: input.analysisString ?? null, + analysis: newValues, + error: null, + }); +}; + +// Tests: +// - Should add the new key +// - Should dispatch action accordingly +// - updateFile must be called with analysisFileId as the target +// - everything must be called once only +test("onOutputMetricsModalSave: Add a new output metric", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysisString: "clearly hardcoded", + analysis: { outputs: {}, plots: [] }, + data: { + title: "theMetricTitle", + operations, + }, + }; + const newValues = { + outputs: { + [input.data.title]: input.data.operations, + }, + plots: [], + }; + onOutputMetricsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +// Tests: +// - All from the "Create" test +// - Edit an existing metric should delete the older key +// - should dispatch updateFile with analysisFileId and right contents +// - should call setAnalysis with the right parameters +test("onOutputMetricsModalSave: Edit an existing metric", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { outputs: {}, plots: [] }, + data: { + title: "theMetricTitleUpdated", + operations, + }, + plotKey: "theMetricTitle", + }; + const newValues = { + outputs: { + [input.data.title]: input.data.operations, + }, + plots: [], + }; + onOutputMetricsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +test("onOutputMetricsModalDelete: Deletes an existing metric", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + theMetricTitle: { + title: "theMetricTitle", + operations, + }, + }, + plots: [], + }, + keyToDelete: "theMetricTitle", + }; + const newValues = { + outputs: {}, + plots: [], + }; + onOutputMetricsModalDelete(input); + baseWriteToFileTests(input, newValues); +}); + +test("onOutputMetricsModalDelete: Trying to delete a non existing metric returns void", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + theMetricTitle: { + title: "theMetricTitle", + operations, + }, + }, + plots: [], + }, + keyToDelete: "IClearlyDoNotExist", + }; + expect(onOutputMetricsModalDelete(input)).toBeUndefined(); + expect(input.dispatch).not.toHaveBeenCalled(); + expect(input.setAnalysis).not.toHaveBeenCalled(); +}); + +test("onDuplicateMetric: duplicates metric", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + theMetricTitle: { + title: "theMetricTitle", + operations, + }, + }, + plots: [], + }, + metricKey: "theMetricTitle", + }; + const newValues = { + outputs: Object.assign({}, input.analysis.outputs, { + theMetricTitle_copy: { + title: "theMetricTitle", + operations, + }, + }), + plots: [], + }; + onDuplicateMetric(input); + baseWriteToFileTests(input, newValues); +}); + +test("onDuplicateMetric: fails to duplicate metric if it does not exist", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + theMetricTitle: { + title: "theMetricTitle", + operations, + }, + }, + plots: [], + }, + metricKey: "ThisOneDoesNotExistAndIKnowIt", + }; + expect(onDuplicateMetric(input)).toBeUndefined(); + expect(input.dispatch).not.toHaveBeenCalled(); + expect(input.setAnalysis).not.toHaveBeenCalled(); +}); + +test("onPlotsModalDelete: deletes the plot", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + theMetricTitle: { + title: "theMetricTitle", + operations, + }, + }, + plots: [ + { + title: "Such a nice title", + type: "bar", + data: [{ y: "theMetricTitle", name: "theMetrictitle" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + ], + }, + indexToDelete: 0, + }; + const newValues = { + outputs: Object.assign({}, input.analysis.outputs), + plots: [], + }; + onPlotsModalDelete(input); + baseWriteToFileTests(input, newValues); +}); + +test("onPlotsModalDelete: avoids deleting the plot if it doesnt exist", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + theMetricTitle: { + title: "theMetricTitle", + operations, + }, + }, + plots: [ + { + title: "Such a nice title", + type: "bar", + data: [{ y: "theMetricTitle", name: "theMetrictitle" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + ], + }, + indexToDelete: 42, + }; + expect(onPlotsModalDelete(input)).toBeUndefined(); + expect(input.dispatch).not.toHaveBeenCalled(); + expect(input.setAnalysis).not.toHaveBeenCalled(); +}); + +// Tests: +// - Should add the new key +// - Should dispatch action accordingly +// - updateFile must be called with analysisFileId as the target +// - everything must be called once only +test("onPlotsModalSave: Add a new Plot", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysisString: "clearly hardcoded", + analysis: { + outputs: { + field1: { + title: "field1", + operations, + }, + }, + plots: [], + }, + data: { + title: "theMetricTitle", + chartType: { value: ChartTypes.area, label: ChartTypes.area }, + position: { x: "0%", y: "0%" }, + yitems: [ + { name: "field1", metric: "field1" }, + { name: "field2", metric: "field2" }, + ], + layout: { width: "100%", height: "50%" }, + }, + }; + const newValues = Object.assign({}, input.analysis, { + plots: [ + { + title: input.data.title, + type: ChartTypes.area, + data: [ + { y: "field1", stackgroup: "one", name: "field1" }, + { y: "field2", stackgroup: "one", name: "field2" }, + ], + layout: input.data.layout, + position: input.data.position, + }, + ], + }); + + onPlotsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +test("onPlotsModalSave: Add a new 'box' Plot", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysisString: "clearly hardcoded", + analysis: { + outputs: { + field1: { + title: "field1", + operations, + }, + }, + plots: [], + }, + data: { + title: "theMetricTitle", + chartType: { value: ChartTypes.box, label: ChartTypes.box }, + yitems: [{ name: "plot label", metric: "field1" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + }; + const newValues = Object.assign({}, input.analysis, { + plots: [ + { + title: input.data.title, + type: ChartTypes.box, + data: [{ y: "field1", name: "plot label" }], + layout: input.data.layout, + position: input.data.position, + }, + ], + }); + onPlotsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +// Tests: +// - All from the "Create" test +// - Edit an existing Plot should update the existing plot +// - should dispatch updateFile with analysisFileId and right contents +// - should call setAnalysis with the right parameters +test("onPlotsModalSave: Edit an existing metric", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + field1: { + title: "field1", + operations: [ + { op: "filter", field: "field1", comparison: "eq", value: "value" }, + ], + }, + field2: { + title: "field2", + operations: [ + { op: "filter", field: "field2", comparison: "eq", value: "value" }, + ], + }, + }, + plots: [ + { + title: "theMetricTitle", + type: "bar", + data: [{ y: "field1", name: "field1" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + ], + }, + data: { + title: "thisIsAnUpdatedTitle", + chartType: { value: ChartTypes.area, label: ChartTypes.area }, + yitems: [ + { name: "field1", metric: "field1" }, + { name: "field2", metric: "field2" }, + ], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + plotIndex: 0, + }; + const newValues = Object.assign({}, input.analysis, { + plots: [ + { + title: input.data.title, + type: ChartTypes.area, + data: [ + { y: "field1", stackgroup: "one", name: "field1" }, + { y: "field2", stackgroup: "one", name: "field2" }, + ], + layout: input.data.layout, + position: input.data.position, + }, + ], + }); + onPlotsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +// Tests: + +test("onPlotsModalSave: Edit changing the type to timeseries", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + field1: { + title: "field1", + operations: [ + { op: "filter", field: "field1", comparison: "eq", value: "value" }, + ], + }, + field2: { + title: "field2", + operations: [ + { op: "filter", field: "field2", comparison: "eq", value: "value" }, + ], + }, + }, + plots: [ + { + title: "theMetricTitle", + type: "bar", + data: [{ name: "field1", metric: "field1" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + ], + }, + data: { + title: "thisIsAnUpdatedTitle", + chartType: { value: ChartTypes.timeseries, label: ChartTypes.timeseries }, + yitems: [ + { name: "field1", metric: "field1" }, + { name: "field2", metric: "field2" }, + ], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + plotIndex: 0, + }; + const newValues = Object.assign({}, input.analysis, { + plots: [ + { + title: input.data.title, + type: ChartTypes.timeseries, + data: [ + { y: "field1", name: "field1" }, + { y: "field2", name: "field2" }, + ], + layout: input.data.layout, + position: input.data.position, + }, + ], + }); + onPlotsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +test("onPlotsModalSave: Edit changing type to area", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + field1: { + title: "field1", + operations: [ + { op: "filter", field: "field1", comparison: "eq", value: "value" }, + ], + }, + field2: { + title: "field2", + operations: [ + { op: "filter", field: "field2", comparison: "eq", value: "value" }, + ], + }, + }, + plots: [ + { + title: "theMetricTitle", + type: "bar", + data: [{ y: "field1", name: "field1" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + ], + }, + data: { + title: "thisIsAnUpdatedTitle", + chartType: { value: ChartTypes.area, label: ChartTypes.area }, + yitems: [ + { name: "field1", metric: "field1" }, + { name: "field2", metric: "field2" }, + ], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + plotIndex: 0, + }; + const newValues = Object.assign({}, input.analysis, { + plots: [ + { + title: input.data.title, + type: ChartTypes.area, + data: [ + { y: "field1", stackgroup: "one", name: "field1" }, + { y: "field2", stackgroup: "one", name: "field2" }, + ], + layout: input.data.layout, + position: input.data.position, + }, + ], + }); + onPlotsModalSave(input); + baseWriteToFileTests(input, newValues); +}); + +test("onPlotsModalSave: Edit changing type to histogram", () => { + const input = { + dispatch: jest.fn(), + setAnalysis: jest.fn(), + analysis: { + outputs: { + field1: { + title: "field1", + operations: [ + { op: "filter", field: "field1", comparison: "eq", value: "value" }, + ], + }, + field2: { + title: "field2", + operations: [ + { op: "filter", field: "field2", comparison: "eq", value: "value" }, + ], + }, + }, + plots: [ + { + title: "theMetricTitle", + type: "bar", + data: [{ y: "field1", name: "field1" }], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + ], + }, + data: { + title: "thisIsAnUpdatedTitle", + chartType: { value: ChartTypes.histogram, label: ChartTypes.histogram }, + xitems: [ + { name: "field1", metric: "field1" }, + { name: "field2", metric: "field2" }, + ], + layout: { width: "100%", height: "50%" }, + position: { x: "0%", y: "0%" }, + }, + plotIndex: 0, + }; + const newValues = Object.assign({}, input.analysis, { + plots: [ + { + title: input.data.title, + type: ChartTypes.histogram, + data: [ + { x: "field1", name: "field1" }, + { x: "field2", name: "field2" }, + ], + layout: input.data.layout, + position: input.data.position, + }, + ], + }); + onPlotsModalSave(input); + baseWriteToFileTests(input, newValues); +}); diff --git a/apps/sim-core/packages/core/src/components/Analysis/modals.ts b/apps/sim-core/packages/core/src/components/Analysis/modals.ts new file mode 100644 index 0000000..4e82904 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/modals.ts @@ -0,0 +1,369 @@ +import { PlotDefinition } from "@hashintel/engine-web"; +import { omit } from "lodash"; + +import { ChartTypes, Operation, Plot, YAxisItemType } from "./types"; +import { ParsedAnalysis } from "../../features/files/types"; +import { analysisFileId, stringifyAnalysis } from "../../features/files/utils"; +import { updateFile } from "../../features/files/slice"; + +export const MAGIC_STEPS_KEY = "Use steps on the X Axis"; + +type ModalsBaseProps = { + dispatch: Function; + setAnalysis: Function; + analysis: any; + analysisString?: string; +}; + +type OutputMetricsModalSubmitType = { + title: string; + operations: Operation[]; +}; + +type OnOutputMetricsModalSaveInputType = ModalsBaseProps & { + data: OutputMetricsModalSubmitType; + previousKey?: string; +}; + +type OnOutputMetricsModalDeleteType = ModalsBaseProps & { + keyToDelete: string; +}; + +type OnDuplicateMetricType = ModalsBaseProps & { + metricKey: string; +}; + +type OnPlotsModalDeleteType = ModalsBaseProps & { + indexToDelete: number; +}; + +type PlotsModalChartTypeOption = { + value: string; + label: string; +}; + +type PlotsModalYAxisItemType = { + name: string; + metric: string; +}; + +type PlotsModalXAxisItemType = { + name: string; + metric: string; +}; + +type PlotsModalLayoutType = { + width: string; + height: string; +}; + +type PlotsModalPositionType = { + x: string; + y: string; +}; + +type PlotsModalSubmitType = { + title: string; + chartType: PlotsModalChartTypeOption; + yitems?: PlotsModalYAxisItemType[]; + xitems?: PlotsModalXAxisItemType[]; + layout: PlotsModalLayoutType; + position: PlotsModalPositionType; +}; + +type OnPlotsModalSaveType = ModalsBaseProps & { + data: PlotsModalSubmitType; + plotIndex?: number; +}; + +type saveToAnalysisFile = { + dispatch: Function; + setAnalysis: Function; + analysisString?: string; + newValues: any; +}; + +const saveToAnalysisFile = ({ + dispatch, + setAnalysis, + analysisString, + newValues, +}: saveToAnalysisFile) => { + const contents = stringifyAnalysis(newValues); + dispatch(updateFile({ id: analysisFileId, contents })); + setAnalysis({ + lastAnalysisString: analysisString ?? null, + analysis: newValues, + error: null, + }); +}; + +export const onOutputMetricsModalSave = ({ + dispatch, + setAnalysis, + analysisString, + analysis, + data, + previousKey, +}: OnOutputMetricsModalSaveInputType) => { + let outputs = { ...analysis.outputs, [data.title]: data.operations }; + if ( + Object.keys(analysis?.outputs ?? {}).length > 0 && + previousKey && + analysis.outputs[previousKey] && + previousKey !== data.title + ) { + outputs = omit(outputs, previousKey); // if we changed the title, delete the old one to avoid duplicates + } + const newValues: ParsedAnalysis = { ...analysis, outputs: outputs }; + saveToAnalysisFile({ dispatch, setAnalysis, analysisString, newValues }); +}; + +export const onOutputMetricsModalDelete = ({ + dispatch, + setAnalysis, + analysisString, + analysis, + keyToDelete, +}: OnOutputMetricsModalDeleteType) => { + if (!analysis?.outputs?.[keyToDelete]) { + return; + } + const outputs = omit(analysis.outputs, keyToDelete); + const newValues: ParsedAnalysis = { ...analysis, outputs: outputs }; + saveToAnalysisFile({ dispatch, setAnalysis, analysisString, newValues }); +}; + +export const onDuplicateMetric = ({ + metricKey, + analysis, + analysisString, + dispatch, + setAnalysis, +}: OnDuplicateMetricType) => { + if (!analysis?.outputs?.[metricKey]) { + return; + } + const newOutputs = Object.assign({}, analysis.outputs); + const metricKeyUntilUnderscoreCopy = metricKey.split("_copy")[0]; + let newKey = `${metricKeyUntilUnderscoreCopy}_copy`; + let newKeyIndex = 1; + while (newOutputs[newKey]) { + newKeyIndex++; + newKey = `${metricKeyUntilUnderscoreCopy}_copy${newKeyIndex}`; + } + newOutputs[newKey] = newOutputs[metricKey]; + const newValues: ParsedAnalysis = { ...analysis, outputs: newOutputs }; + saveToAnalysisFile({ dispatch, setAnalysis, analysisString, newValues }); +}; + +// Reads the data definition and transforms it to a format understood +// by the Plots modal +export const getYAxisItemsFromDataDefinition = ( + input: PlotDefinition & any +): Array => { + if (!input.type && input[ChartTypes.timeseries]) { + return input.timeseries.map((metric: any) => ({ + name: metric, + metric, + })); + } + switch (input.type) { + // http://localhost:8080/@hash/city-infection-model/6.1.1 + case ChartTypes.area: + case ChartTypes.bar: + case ChartTypes.box: + case ChartTypes.timeseries: + return ( + input.data?.map((item: any) => ({ + name: item.name ?? item.y, + metric: item.y, + })) ?? [] + ); + + default: + return ( + input.data + ?.filter((item: any) => item.y) + .map((item: any) => ({ + name: item.y, + metric: item.y, + })) ?? [] + ); + } +}; + +// Reads the data definition and transforms it to a format understood +// by the Plots modal +export const getXAxisItemsFromDataDefinition = ( + input: PlotDefinition & any +): Array => { + if (!input.type) { + return input.data; + } + return ( + input.data + ?.filter((item: any) => item.x) + .map((item: any) => ({ + name: item.x, + metric: item.x, + })) ?? [] + ); +}; + +export const getPlotTypeFromDataDefinition = ( + input: PlotDefinition & any +): string => input.type ?? ChartTypes.timeseries; + +const chartItemLabel = (item: { name?: string; metric?: string }) => + item.name ?? item.metric; + +export const transformPlotDataBasedOnChartType = ( + input: PlotDefinition & any +) => { + const result = Object.assign({}, input); + switch (input.type) { + // http://localhost:8080/@hash/city-infection-model/6.1.1 + case ChartTypes.area: + result.data = input.data?.yitems?.map((item: any) => ({ + y: item.metric, + stackgroup: "one", + name: chartItemLabel(item), + })); + break; + + case ChartTypes.box: + result.data = input.data?.yitems?.map((item: any) => ({ + y: item.metric, + name: chartItemLabel(item), + })); + break; + + // http://localhost:8080/@hash/boids-3d/6.0.0 + case ChartTypes.timeseries: + delete result.timeseries; + result.type = "timeseries"; + result.data = input.data?.yitems?.map((item: any) => ({ + y: item.metric, + name: chartItemLabel(item), + })); + break; + + // http://localhost:8080/@hash/model-market/4.2.0 + case ChartTypes.histogram: + if (input.data?.xitems?.length > 0) { + result.data = input.data?.xitems?.map((item: any) => ({ + x: item.metric, + name: chartItemLabel(item), + })); + } else { + result.data = input.data?.yitems?.map((item: any) => ({ + y: item.metric, + name: chartItemLabel(item), + })); + } + break; + + case ChartTypes.line: + case ChartTypes.scatter: + // this assumes we have both X and Y OR we have only Y and X=steps + const hasMagicStepsKey = + input.data?.xitems.length === 1 && + input.data.xitems[0].metric === MAGIC_STEPS_KEY; + const hasYItems = input.data?.yitems.length > 0 ?? false; + const hasXItems = hasMagicStepsKey + ? false + : input.data?.xitems.length > 0; + if (!hasYItems && !hasXItems) { + console.log( + "Caught invalid configuration for line or scatter plot. The validation for this should be added to the Plots modal." + ); + result.data = []; // we shouldnt get to this case, so prevent writing invalid stuff + } + if (hasYItems && !hasXItems) { + result.data = input.data?.yitems?.map((item: any) => ({ + y: item.metric, + name: chartItemLabel(item), + })); + } + if (hasYItems && hasXItems) { + result.data = input.data?.yitems?.map((item: any, index: number) => ({ + y: item.metric, + x: input.data?.xitems?.[index].metric, + })); + } + + break; + + case ChartTypes.bar: + default: + result.data = input.data?.yitems?.map((item: any) => ({ + y: item.metric, + name: chartItemLabel(item), + })); + } + return result; +}; + +export const onPlotsModalSave = ({ + data, + plotIndex, + analysis, + analysisString, + dispatch, + setAnalysis, +}: OnPlotsModalSaveType) => { + // Prevent error if `plots` property isnt available + const newAnalysis = { ...analysis }; + if (!newAnalysis.plots) { + newAnalysis.plots = []; + } + const plots: Plot[] = [...newAnalysis.plots]; + const newItem = transformPlotDataBasedOnChartType({ + title: data.title, + type: data.chartType.value, + data: { yitems: data.yitems, xitems: data.xitems }, + layout: data.layout, + position: data.position, + }); + if (typeof plotIndex === "number") { + newItem.layout = newItem.layout ?? plots[plotIndex].layout; + newItem.position = plots[plotIndex].position; + plots[plotIndex] = newItem; + } else { + plots.push(newItem); + } + + const newValues: ParsedAnalysis = { ...newAnalysis, plots: plots }; + saveToAnalysisFile({ dispatch, setAnalysis, analysisString, newValues }); +}; + +export const onPlotsModalDelete = ({ + indexToDelete, + analysis, + setAnalysis, + dispatch, + analysisString, +}: OnPlotsModalDeleteType) => { + if (!analysis?.plots?.[indexToDelete]) { + return; + } + // remove the item and reset the Y position + let combinedHeight = 0; + const plots = [...analysis.plots] + .filter((_item, index) => index !== indexToDelete) + .map((item: any) => { + const result = { + ...item, + position: { + x: item.position.x, + y: `${combinedHeight}%`, + }, + }; + combinedHeight = + combinedHeight + parseInt(item.layout.height.replace("%", ""), 10); + return result; + }); + const newValues: ParsedAnalysis = { ...analysis, plots: plots }; + saveToAnalysisFile({ dispatch, setAnalysis, analysisString, newValues }); +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/types.ts b/apps/sim-core/packages/core/src/components/Analysis/types.ts new file mode 100644 index 0000000..19cacf3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/types.ts @@ -0,0 +1,188 @@ +import { MouseEvent } from "react"; +import { PlotParams } from "react-plotly.js"; + +import { AnalysisMode } from "../../features/simulator/simulate/enum"; +import { ReactSelectOption } from "../Dropdown/types"; + +export type AnalysisProps = { + currentStep: number; + visible?: boolean; +}; + +export type AnalysisViewerPlotsTabProps = { + analysisPlotsDataAvailable: boolean; + analysisOutputMetricsDataAvailable: boolean; + currentStep: number; + outputs: { [index: string]: any[] }; + analysisMode?: AnalysisMode | null; + onPlotsModalSaveHandler: Function; + onPlotsModalDeleteHandler: Function; + showPlotsModal: (event: MouseEvent) => void; + readonly: boolean; +}; + +export type AnalysisViewerOutputMetricsTabProps = { + analysisOutputMetricsDataAvailable: boolean; + showOutputMetricsModal: (event: MouseEvent) => void; + analysis: any; + onOutputMetricsModalSaveHandler: Function; + onOutputMetricsModalDeleteHandler: Function; + onDuplicateMetricHandler: Function; + readonly: boolean; +}; + +export type OutputPlotProps = PlotParams & { + key: string; + style: any; + hideStep?: boolean; +}; + +export enum ButtonCallToActionType { + METRICS = "METRICS", + PLOTS = "PLOTS", +} + +export type ButtonCallToActionProps = { + children: JSX.Element | JSX.Element[]; + onClick?: (event: MouseEvent) => void; +}; + +export type AnalysisViewerActionButtonsProps = { + canCreateNewPlot?: boolean; + showOutputMetricsModal: (event: MouseEvent) => void; + showPlotsModal: (event: MouseEvent) => void; + + /** + * This has to be true, as AnalysisViewerActionButtons cannot render without + * it + */ + canEdit: true; +}; + +export enum ComparisonTypes { + eq = "eq", + neq = "neq", + lt = "lt", + lte = "lte", + gt = "gt", + gte = "gte", +} + +export enum OperationTypes { + filter = "filter", + count = "count", + get = "get", + sum = "sum", + min = "min", + max = "max", + mean = "mean", +} + +export type OperationItemProps = { + operation: Operation; + index: number; + onDelete: (event: MouseEvent) => void; + onChange: Function; + permittedOperations: ReactSelectOption[]; // the operations that preceed this one. + hideDelete?: boolean; + behaviorKeysOptions?: ReactSelectOption[]; // used for "field" +}; + +export type Operation = { + op: OperationTypes; + field?: string; + comparison?: ComparisonTypes; + value?: any; +}; + +export type OutputMetricsGridProps = { + onOutputMetricsModalSave: Function; + metrics?: { [index: string]: Operation[] }; + onOutputMetricsModalDelete?: Function; + onDuplicateMetric?: Function; + sizeClassname?: string; + readonly: boolean; +}; + +type PlotLayout = { + width: string; + height: string; +}; + +type PlotPosition = { + x: string; + y: string; +}; + +enum PlotType { + timeseries, + histogram, + barplot, + line, +} + +type PlotData = { + y: string; + name: string; +}; + +export type Plot = { + title: string; + layout: PlotLayout; + position: PlotPosition; + type?: PlotType; + data?: PlotData[]; + timeseries?: string[]; +}; + +export type AnalysisObject = { + outputs: { [index: string]: Operation[] }; + plots: Plot[]; +}; + +export type AnalysisState = { + lastAnalysisString?: any; + analysis?: AnalysisObject; + error: any; +}; + +export type OnOutputMetricsModalSaveType = { + title: string; + operations: Operation[]; +}; + +export type OnOutputMetricsModalSaveProps = { + data: OnOutputMetricsModalSaveType; +}; + +export enum ChartTypes { + area = "area", + bar = "bar", + box = "box", + heatmap = "heatmap", + timeseries = "timeseries", + histogram = "histogram", + line = "line", + scatter = "scatter", + // Two parameter experiment + // contour = "contour", + // heatmap = "heatmap", + // line3d = "line3d", + // scatter3d = "scatter3d", +} + +type AxisItemType = { + name: string; + metric: string; +}; +export type YAxisItemType = AxisItemType; +export type XAxisItemType = AxisItemType; + +export type YAxisItemProps = { + item: YAxisItemType; + index: number; + metricKeysOptions: ReactSelectOption[]; + onDelete: (event: MouseEvent) => void; + onChange: Function; + hideDelete: boolean; +}; diff --git a/apps/sim-core/packages/core/src/components/Analysis/utils.ts b/apps/sim-core/packages/core/src/components/Analysis/utils.ts new file mode 100644 index 0000000..7b49a76 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Analysis/utils.ts @@ -0,0 +1,118 @@ +import { ComparisonTypes } from "./types"; +import { ParsedAnalysis } from "../../features/files/types"; +import { safeParseJsonTracked } from "../../util/safeParseJsonTracked"; + +const _flattenError = (error: string): string => { + const removeAllWhitespacesRegex = /\r?\n|\r/g; + return error.replace(removeAllWhitespacesRegex, "").replace(" ", " "); +}; + +// the last part of the error is always the position +const _getErrorPosition = (error: string): number => + Number(error.split(" ").pop()); + +const _getErrorLineInformation = ( + sourceCode: string, + errorPosition: number +): { + slicedJsonLines: string[]; + leftSpacePaddingLength: number; + lineForError: number; +} => { + // extract since the beginning of the file until the error happens + const slicedJson = sourceCode?.slice(0, errorPosition); + let leftSpacePaddingLength = 0; + let lineForError = 0; + // split into lines, then start searching for the specific character + // once the character is found, we'll have lineForError set + const slicedJsonLines = slicedJson.split("\n"); + let currentChar = 1; + slicedJsonLines.forEach((line, currentLine) => { + const lineLength = line.length; + lineForError = currentLine; + if (lineLength + currentChar < errorPosition) { + // the error is not in this line, increase counter and continue + currentChar += lineLength; + return; + } + const matchingChar = sourceCode?.slice(errorPosition - 1, errorPosition); + leftSpacePaddingLength = line.substring(0, line.indexOf(matchingChar)) + .length; + }); + return { slicedJsonLines, leftSpacePaddingLength, lineForError }; +}; + +const _getErrorWithSurroundingCode = ({ + lineForError, + leftSpacePaddingLength, + slicedJsonLines, +}: { + lineForError: number; + leftSpacePaddingLength: number; + slicedJsonLines: string[]; +}) => { + const lineForErrorStr = `${lineForError - 1}: `; + const spacer = " ".repeat(leftSpacePaddingLength + lineForErrorStr.length); + // TODO: improvements: add line number to the following lines until the error + // TODO: add heuristic to look for common flaws like missing string literal + // for example: `{ "outputs:` + const result = slicedJsonLines + .slice(lineForError - 2, lineForError) + .map( + (item: string, index: number) => + `${lineForError - 1 + index}: ${spacer}${item}` + ) + .join("\n"); // we can comment this line but sometimes the error is way too long + + return `${result} ⟵ The error happened on this line`; +}; + +export const getHelpForSyntaxError = ( + error: string, + analysisString?: string +): string => { + if (!analysisString) { + return ""; + } + const flattened = _flattenError(error); + const errorPosition = _getErrorPosition(flattened); + if (!errorPosition) { + return flattened; + } + const params = _getErrorLineInformation(analysisString, errorPosition); + return _getErrorWithSurroundingCode(params); +}; + +export const getHumanReadableComparison = (op: ComparisonTypes) => { + switch (op) { + case ComparisonTypes.eq: + return "equals"; + + case ComparisonTypes.neq: + return "is not equal to"; + + case ComparisonTypes.lt: + return "is less than"; + + case ComparisonTypes.lte: + return "equals or is less than"; + + case ComparisonTypes.gt: + return "is greater than"; + + case ComparisonTypes.gte: + return "equals or is greater than"; + + default: + return op; + } +}; + +export const parseAnalysis = (input?: string) => { + const result = safeParseJsonTracked(input); + return { + lastAnalysisString: input, + analysis: result.parsed, + error: result.error, + }; +}; diff --git a/apps/sim-core/packages/core/src/components/App/App.css b/apps/sim-core/packages/core/src/components/App/App.css new file mode 100644 index 0000000..29df75d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/App/App.css @@ -0,0 +1,105 @@ +.App { + background: transparent; + + width: 100%; + height: 100%; + min-width: var(--theme-min-width); + overflow: hidden; + + display: flex; + flex-direction: column; + position: relative; + + --splitter-hit-area: 7px; + --splitter-visible-area: 1px; + --splitter-hit-offset: calc( + -1 * ((var(--splitter-hit-area) - var(--splitter-visible-area)) / 2) + ); +} + +.App .splitter-layout > .layout-splitter { + z-index: 4; + position: relative; + background: var(--theme-border) !important; + height: 100%; +} + +.App .splitter-layout:not(.splitter-layout-vertical) > .layout-splitter, +.App .splitter-layout:not(.splitter-layout-vertical) > .layout-splitter:after { + width: var(--splitter-visible-area); +} + +/* There is an issue to add support for this to the project. + In lieu of that, I came up with this + The issue: https://github.com/zesik/react-splitter-layout/issues/51#issue-537237116 */ +.App .splitter-layout > .layout-splitter:before, +.App .splitter-layout > .layout-splitter:after { + content: ""; + display: block; + position: absolute; + top: 0; + height: 100%; +} + +.App .splitter-layout > .layout-splitter:after { + background: var(--theme-border); + left: 0; + transition: transform 0.1s cubic-bezier(0.23, 1, 0.32, 1); +} + +.App .splitter-layout > .layout-splitter:hover:after { + transition-delay: 0.5s; +} + +.App .splitter-layout > .layout-splitter:before { + width: var(--splitter-hit-area); + left: var(--splitter-hit-offset); +} + +.App .splitter-layout--right > .layout-splitter:before { + left: 0; + width: calc(var(--splitter-hit-area) - calc(var(--splitter-hit-offset) * -1)); +} + +.App .splitter-layout > .layout-splitter:hover:after, +.App .splitter-layout.layout-changing > .layout-splitter:after { + transform: scale(7, 1); +} + +.App .splitter-layout > .layout-splitter:hover:after, +.App .splitter-layout.layout-changing > .layout-splitter:after { + background: var(--theme-border-hover); +} + +.App .splitter-layout.splitter-layout-vertical > .layout-splitter, +.App .splitter-layout.splitter-layout-vertical > .layout-splitter:after { + height: var(--splitter-visible-area); +} + +.App .splitter-layout.splitter-layout-vertical > .layout-splitter:before, +.App .splitter-layout.splitter-layout-vertical > .layout-splitter:after { + width: 100%; + left: 0; +} + +.App .splitter-layout.splitter-layout-vertical > .layout-splitter:before { + height: var(--splitter-hit-area); + top: var(--splitter-hit-offset); +} + +.App .splitter-layout.splitter-layout-vertical > .layout-splitter:hover:after, +.App + .splitter-layout.layout-changing + > .layout-splitter.splitter-layout-vertical:after { + transform: scale(1, 7); + left: 0; +} + +.App .splitter-layout.layout-changing > .layout-splitter:after { + background: var(--theme-border-drag); +} + +.App .layout-splitter-no-transition-delay:after, +.App .splitter-layout.layout-changing > .layout-splitter:after { + transition-delay: 0s !important; +} diff --git a/apps/sim-core/packages/core/src/components/App/App.tsx b/apps/sim-core/packages/core/src/components/App/App.tsx new file mode 100644 index 0000000..cc018d7 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/App/App.tsx @@ -0,0 +1,34 @@ +import React, { FC } from "react"; +import { Provider } from "react-redux"; +import { ModalProvider } from "react-modal-hook"; +import { Store } from "@reduxjs/toolkit"; +import { RecoilRoot } from "recoil"; + +import { ErrorBoundary } from "../ErrorBoundary"; +import { FontsPreloader } from "../FontsPreloader"; +import { MonacoContainerProvider } from "../TabbedEditor/hooks"; +import { SimulatorProvider } from "../../features/simulator/context"; + +import "./App.css"; + +type AppProps = { + store: Store; +}; + +export const App: FC = ({ store, children }) => ( + + + + + + + +
    {children}
    +
    +
    +
    +
    +
    +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/App/index.ts b/apps/sim-core/packages/core/src/components/App/index.ts new file mode 100644 index 0000000..713869c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/App/index.ts @@ -0,0 +1 @@ +export { App } from "./App"; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.css b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.css new file mode 100644 index 0000000..d8debc9 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.css @@ -0,0 +1,14 @@ +.BehaviorKeys { + --padding-horizontal: 25px; + --bk-row-spacing: 14px; + --bk-row-height: 38px; + + background-color: #0d0f13; + padding-top: 20px; + padding-bottom: 14px; + overflow: auto; +} + +.BehaviorKeys > * { + min-width: 340px; +} diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.tsx new file mode 100644 index 0000000..92e1af9 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeys.tsx @@ -0,0 +1,61 @@ +import React, { FC, useMemo, useState } from "react"; + +import { + BehaviorKeysDraftRow, + DraftBehaviorKeys, +} from "../../features/files/behaviorKeys"; +import { BehaviorKeysForm } from "./BehaviorKeysForm"; +import { BehaviorKeysProjection } from "./BehaviorKeysProjection"; +import { Projection } from "./types"; +import { assignProjection, reduceProjection } from "./project"; + +import "./BehaviorKeys.css"; + +export const BehaviorKeys: FC<{ + data: DraftBehaviorKeys; + onChange: (data: DraftBehaviorKeys) => void; + fileId: string; + autosuggest: boolean; + disabled: boolean; +}> = ({ data, onChange, fileId, autosuggest, disabled }) => { + const [projection, setProjection] = useState([]); + + const projectedData = useMemo(() => reduceProjection(projection, data), [ + projection, + data, + ]); + + const onProjectedDataChange = (rows: BehaviorKeysDraftRow[]) => { + if (!disabled) { + onChange(assignProjection(projection, data, rows)); + } + }; + + const onProjectionChange = (newFieldIdx: number) => { + setProjection([ + ...projection, + { + idx: newFieldIdx, + label: projectedData.rows[newFieldIdx][0], + }, + ]); + }; + + return ( +
    + + +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.scss b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.scss new file mode 100644 index 0000000..6695bff --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.scss @@ -0,0 +1,81 @@ +.BehaviorKeys__FieldForm { + --field-background: rgba(255, 255, 255, 0.1); + + .RoundedTextInput { + --background-color: var(--field-background); + input { + } + } + + .RoundedSelect { + background: var(--field-background); + } + + .RoundedSelect, + .RoundedTextInput input { + height: var(--bk-row-height); + border: var(--input-border); + border-radius: 7px; + } + + > *:not(:last-child) { + margin-right: 10px; + } +} + +.BehaviorKeys__FieldForm__Container { + flex: 1; + display: flex; + align-items: center; + max-width: 372px; + min-width: 276px; + + > *:not(:last-child) { + margin-right: 10px; + } +} + +.BehaviorKeys__FieldForm__FieldName { + flex: 1; +} + +.BehaviorKeys__FieldForm__Type { + min-width: 116px; +} + +.BehaviorKeys__FieldForm__ListLength { + width: 60px; +} + +.BehaviorKeys__FieldForm__Button { + --size: 30px; + background-color: transparent; + border: 1px solid rgba(255, 255, 255, 0.15); + box-sizing: border-box; + padding: 0; + width: var(--size); + height: var(--size); + flex: 0 0 var(--size); + border-radius: var(--size); + font-weight: bold; + fill: white; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.BehaviorKeys__FieldForm__Button:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +.BehaviorKeys__FieldForm__Button:not(:last-child) { + margin-right: 5px; +} + +.BehaviorKeys__FieldForm__Button--error { + border: none; + &, + &:hover { + background-color: var(--theme-red); + } +} diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.tsx new file mode 100644 index 0000000..d840b3c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldForm.tsx @@ -0,0 +1,170 @@ +import React, { FC, useState } from "react"; +import { batch } from "react-redux"; +import classNames from "classnames"; +import { debounce } from "lodash"; + +import { + BehaviorKeysField, + fieldHasRows, +} from "../../features/files/behaviorKeys"; +import { BehaviorKeysFieldFormProps } from "./types"; +import { BehaviorKeysFieldPopover } from "./BehaviorKeysFieldPopover"; +import { BehaviorKeysFieldPopoverOptions } from "./BehaviorKeysFieldFormPopoverOptions"; +import { BehaviorKeysRow } from "./BehaviorKeysRow"; +import { IconAlert } from "../Icon/Alert"; +import { IconMore } from "../Icon/More"; +import { IconTree } from "../Icon/Tree"; +import { RoundedSelect } from "../Inputs/Select/RoundedSelect"; +import { RoundedTextInput } from "../Inputs/RoundedTextInput"; +import { behaviorKeysRowTypes } from "../../features/files/utils"; +import { castField } from "./utils"; +import { useKeyboardShortcuts } from "../../hooks/useKeyboardShortcuts"; +import { validateBehaviorKeyName } from "../../features/files/validate"; +import { validateClash } from "./validate"; + +import "./BehaviorKeysFieldForm.scss"; + +export const BehaviorKeysFieldForm: FC = ({ + fieldName, + row, + clash, + projection, + onRowChange, + onProject, + onRemove, + canModifyFields, + canRemoveField, + onAddField, + onNameChange, + onNameCommit, + typeDisabled, + disabled, + emptyName, +}) => { + const rootField = !projection.length; + const trimmedFieldName = fieldName.trim(); + const [isOptionsOpen, setIsOptionsOpen] = useState(false); + const [isErrorOpen, setIsErrorOpen] = useState(false); + + const clashError = validateClash(clash); + const errors = [ + ...validateBehaviorKeyName(trimmedFieldName, rootField), + ...(clashError ? [clashError] : []), + ...(emptyName ? ["Your field must have a name"] : []), + ]; + + useKeyboardShortcuts( + isOptionsOpen + ? { + single: { + Escape() { + setIsOptionsOpen(false); + }, + }, + } + : {} + ); + + const debouncedOnAdd = debounce(onAddField); + + return ( + +
    + {canModifyFields ? ( + { + const value = evt.target.value; + batch(() => { + setIsErrorOpen(false); + onNameChange(value); + }); + }} + onBlur={() => { + onNameCommit(); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + debouncedOnAdd(); + } + }} + placeholder="Field Name" + className="BehaviorKeys__FieldForm__FieldName" + disabled={disabled} + /> + ) : null} + + type === "any" && projection.length > 0 ? [] : [{ value: type }] + )} + value={row.meta.type} + onChange={(evt) => { + const value = evt.target.value as BehaviorKeysField["type"]; + + onRowChange((draft) => castField(draft, value)); + }} + disabled={disabled || typeDisabled} + /> + {errors.length ? ( + ( +

    {error}

    + ))} + > +
    setIsErrorOpen(true)} + onMouseOut={() => setIsErrorOpen(false)} + className="BehaviorKeys__FieldForm__Button BehaviorKeys__FieldForm__Button--error" + > + +
    +
    + ) : null} + {fieldHasRows(row) ? ( + + ) : null} + setIsOptionsOpen(false)} + content={ + + } + > + + +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldFormPopoverOptions.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldFormPopoverOptions.tsx new file mode 100644 index 0000000..737732d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldFormPopoverOptions.tsx @@ -0,0 +1,84 @@ +import React, { FC } from "react"; + +import { BehaviorKeysFieldFormProps } from "./types"; +import { RoundedTextInput } from "../Inputs/RoundedTextInput"; + +export const BehaviorKeysFieldPopoverOptions: FC< + Pick< + BehaviorKeysFieldFormProps, + "row" | "onRowChange" | "canModifyFields" | "canRemoveField" | "onRemove" + > & { disabled: boolean; typeDisabled: boolean } +> = ({ + row, + onRowChange, + canModifyFields, + canRemoveField, + onRemove, + disabled, + typeDisabled, +}) => ( + <> + + {row.meta.type === "fixed_size_list" ? ( + + ) : null} + {canModifyFields ? ( + + ) : null} + +); diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.css b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.css new file mode 100644 index 0000000..e0a8b9a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.css @@ -0,0 +1,72 @@ +:root { + --BehaviorKeys__Popover--background: var(--theme-light-on-dark); +} + +.BehaviorKeys__PopoverContainer { + /* using a filter instead of box-shadow so there is a shadow on the arrow too */ + filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.25)); +} + +.BehaviorKeys__Popover { + background-color: var(--BehaviorKeys__Popover--background); + padding: 5px; + border-radius: 7px; + width: 200px; +} + +.BehaviorKeys__Popover--error { + max-width: 250px; + width: auto; + pointer-events: none; + padding-left: 15px; + padding-right: 15px; + text-align: center; + font-size: 0.8em; +} + +.BehaviorKeys__PopoverSection { + user-select: none; + border-radius: 3px; + background-color: var(--theme-dark); + text-align: center; + display: flex; + align-items: center; + padding: 5px 10px; + font-size: 13px; + font-weight: bold; + font-family: var(--font); + border: 0; + width: 100%; + box-sizing: border-box; +} + +button.BehaviorKeys__PopoverSection { + justify-content: center; +} + +.BehaviorKeys__PopoverSection:not(:last-child) { + margin-bottom: 5px; +} + +.BehaviorKeys__PopoverSection input, +.BehaviorKeys__PopoverSection .RoundedTextInput { + margin-left: auto; +} + +.BehaviorKeys__PopoverSection--project:hover { + background-color: var(--theme-blue); +} + +.BehaviorKeys__PopoverSection--remove { + background-color: var(--theme-red); + margin-top: 10px; +} + +.BehaviorKeys__PopoverSection--remove:disabled { + opacity: 0.8; + cursor: default; +} + +.BehaviorKeys__PopoverSection--remove:not(:disabled):hover { + background-color: var(--theme-red-alt); +} diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.tsx new file mode 100644 index 0000000..d315b01 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysFieldPopover.tsx @@ -0,0 +1,50 @@ +import React, { FC, HTMLProps, ReactNode } from "react"; +import Popover, { ArrowContainer, PopoverProps } from "react-tiny-popover"; +import classNames from "classnames"; + +import "./BehaviorKeysFieldPopover.css"; + +export const BehaviorKeysFieldPopover: FC< + Pick & { + type?: string; + content: ReactNode; + } & Omit, "content"> +> = ({ + isOpen, + onClickOutside, + type, + children, + content, + className, + ...props +}) => ( + ( + +
    + {content} +
    +
    + )} + > + {children} +
    +); diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.scss b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.scss new file mode 100644 index 0000000..19c342e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.scss @@ -0,0 +1,80 @@ +.BehaviorKeys__FormWrapper { + position: relative; +} + +.BehaviorKeys__Form { + list-style: none; + margin: 0; + padding: 0; + /** + * Ensure maximum 4 rows are visible (with only half spacing after the + * final row) + */ + max-height: calc( + ((var(--bk-row-height) + var(--bk-row-spacing)) * 4) - + (var(--bk-row-spacing) / 2) + ); + overflow: auto; + position: relative; +} + +.BehaviorKeys__Form__Buttons { + margin: 17px 0 3px; +} + +.BehaviorKeys__Form__Buttons__Button { + font-weight: bold; + white-space: nowrap; + height: 36px !important; + + &:not(:last-child) { + margin-right: 13px; + } +} + +.BehaviorKeys__Form__Buttons__Button--autosuggest { + background: var(--theme-dark); + border: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; + color: white; + fill: white; + + &:hover { + background: var(--theme-dark-hover); + } +} + +.BehaviorKeys__DynamicAccessCheckbox, +.BehaviorKeys__ReadOnlyWarning { + font-size: 0.85rem; +} + +.BehaviorKeys__DynamicAccessCheckbox { + padding: 0 1rem 0 1.5rem; + margin: 1rem 0 1rem 0; + fill: white; + + label { + display: flex; + align-items: center; + user-select: none; + } + + .CheckboxInput { + margin-right: 0.5rem; + } + + a { + margin: 0.25rem 0 0 0.5rem; + } +} + +.BehaviorKeys__ReadOnlyWarning { + padding: 0 1.5rem 1rem; + margin: 0; +} + +.BehaviorKeys__ReadOnlyWarning--disabled { + opacity: 0.66; + line-height: 1.3; +} diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.tsx new file mode 100644 index 0000000..b14bd6c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysForm.tsx @@ -0,0 +1,291 @@ +import React, { FC, useEffect, useRef, useState } from "react"; +import { batch, useDispatch, useSelector } from "react-redux"; +import produce, { Draft } from "immer"; + +import { + BehaviorKeysDraftField, + BehaviorKeysDraftFieldWithRows, + BehaviorKeysDraftRow, + calculateRowClashes, +} from "../../features/files/behaviorKeys"; +import { BehaviorKeysFieldForm } from "./BehaviorKeysFieldForm"; +import { BehaviorKeysRow } from "./BehaviorKeysRow"; +import { CheckboxInput } from "../Inputs/Checkbox/CheckboxInput"; +import { FancyButton } from "../Fancy/Button"; +import { FancyButtonAsyncTask } from "../Fancy/Button/FancyButtonAsyncTask"; +import { IconHelpCircle } from "../Icon"; +import { Projection } from "./types"; +import { ScrollFadeShadow } from "../ScrollFade/ScrollFadeShadow"; +import { SimpleTooltip } from "../SimpleTooltip"; +import { addField } from "./utils"; +import { + parseAndShowBehaviorKeys, + updateBehaviorKeysDynamicAccess, +} from "../../features/files/slice"; +import { + selectBehaviorKeysDynamicAccess, + selectSharedBehaviorKeyFieldNames, +} from "../../features/files/selectors"; +import { useAbortingDispatch } from "../../hooks/useAbortingDispatch"; +import { useScrollState } from "../../hooks/useScrollState"; + +import "./BehaviorKeysForm.scss"; + +const scrollToEnd = (scrollable: HTMLUListElement | null) => { + if (scrollable) { + scrollable.scrollTop = scrollable.scrollHeight; + } +}; + +export const BehaviorKeysForm: FC<{ + data: BehaviorKeysDraftFieldWithRows; + onDataChange: (rows: BehaviorKeysDraftRow[]) => void; + onProjectionChange: (idx: number) => void; + projection: Projection; + fileId: string; + autosuggest: boolean; + disabled: boolean; +}> = ({ + data: originalData, + onDataChange, + onProjectionChange, + projection, + fileId, + autosuggest, + disabled, +}) => { + const [draftData, setDraftData] = useState(null); + + if (draftData && draftData.current !== originalData) { + setDraftData(null); + } + + const data = draftData?.draft ?? originalData; + + const onDataChangeRef = useRef(onDataChange); + const clashes = calculateRowClashes(data.rows); + const dispatch = useDispatch(); + const dynamicAccess = useSelector(selectBehaviorKeysDynamicAccess); + + const sharedBehaviorKeyNames = useSelector(selectSharedBehaviorKeyFieldNames); + const lockedNames = projection.length === 0 ? sharedBehaviorKeyNames : []; + const formDisabled = + projection.length === 0 + ? false + : sharedBehaviorKeyNames.includes(projection[0].label); + + const displayWarning = + !disabled && + (formDisabled || + data.rows + .map((row) => row[0]) + .some((name) => lockedNames.includes(name))); + + useEffect(() => { + onDataChangeRef.current = onDataChange; + }); + + const setData = (handler: (draft: Draft) => void) => { + onDataChangeRef.current(produce(data.rows, handler)); + }; + + const canModifyFields = data.key === "fields"; + const listRef = useRef(null); + const [scrollStateRef, contentRemaining] = useScrollState(); + + const [ + dispatchParseAndShowBehaviorKeys, + isParsingDisabled, + ] = useAbortingDispatch(parseAndShowBehaviorKeys, [autosuggest]); + + const focusLast = () => { + const fields = + listRef.current?.querySelectorAll("input[type=text]") ?? + []; + const last = fields[fields.length - 1]; + if (last) { + last.focus(); + last.select(); + } + }; + + const onAddField = () => { + setData((draft) => addField(draft, projection.length === 0)); + setImmediate(() => { + if (listRef.current) { + scrollToEnd(listRef.current); + focusLast(); + } + }); + }; + + return ( + <> +
    + {disabled && !dynamicAccess ? null : ( +
    + +
    + )} + {disabled ? ( +

    + Fields in this behavior cannot be modified. Check that you are on + the "main" version of the project, and that you are editing this + behavior in its original context. +

    + ) : displayWarning ? ( +

    + Some fields in this behavior cannot be modified because they're also + defined in an imported behavior +

    + ) : null} +
      { + listRef.current = ref; + scrollStateRef(ref); + }} + > + {data.rows.map(([fieldName, row], idx) => { + const clash = clashes[idx]; + const realRow = originalData.rows.find( + (originalRow) => originalRow[1].uuid === row.uuid + ); + + const onChange = ( + handler: ( + draft: Draft + ) => void | BehaviorKeysDraftRow + ) => { + setData((draft) => { + const next = handler(draft[idx]); + draft[idx] = typeof next === "undefined" ? draft[idx] : next; + }); + }; + + const onRowChange = ( + handler: ( + draft: Draft + ) => void | BehaviorKeysDraftField + ) => { + onChange((draft) => { + const next = handler(draft[1]); + + draft[1] = typeof next === "undefined" ? draft[1] : next; + }); + }; + + const onProject = () => onProjectionChange(idx); + + const onRemove = () => { + setData((draft) => { + draft.splice(idx, 1); + }); + }; + + return ( + 1} + onNameChange={(newName) => { + const nextData = produce(data, (draft) => { + draft.rows[idx][0] = newName; + }); + + setDraftData({ current: originalData, draft: nextData }); + }} + onNameCommit={() => { + if (draftData) { + batch(() => { + onDataChangeRef.current(draftData.draft.rows); + setDraftData(null); + }); + } + }} + disabled={disabled || formDisabled} + typeDisabled={disabled || lockedNames.includes(fieldName)} + /> + ); + })} +
    + +
    + {canModifyFields && !formDisabled && !disabled ? ( + + { + evt.preventDefault(); + onAddField(); + }} + icon="keyPlus" + theme="blue" + className="BehaviorKeys__Form__Buttons__Button" + > + Add new key + + {autosuggest ? ( + { + await dispatchParseAndShowBehaviorKeys({ fileId }); + }} + icon="autoFix" + theme="dark" + className="BehaviorKeys__Form__Buttons__Button" + disabled={isParsingDisabled} + > + Autosuggest keys + + ) : null} + + ) : null} + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.scss b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.scss new file mode 100644 index 0000000..4320f15 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.scss @@ -0,0 +1,54 @@ +.BehaviorKeys__Projection { + height: 16px; + display: flex; + align-items: center; + font-weight: bold; + text-transform: uppercase; + font-size: 11px; + overflow: hidden; + padding-right: 20px; + padding-left: var(--padding-horizontal); + margin-bottom: 12px; +} + +.BehaviorKeys__Projection__Crumb { + border: 0; + padding: 0; + align-self: stretch; + position: relative; + flex-shrink: 1; + display: flex; + align-items: center; + background-color: transparent; + + .IconChevronRight { + fill: white; + margin-left: -2px; + margin-right: -2px; + } + + &:first-child { + font-weight: bold; + } + + &:last-child .IconChevronRight { + display: none; + } +} + +.BehaviorKeys__Projection__Crumb span { + white-space: nowrap; +} + +.BehaviorKeys__Projection__Crumb--title { + text-transform: uppercase; + flex-shrink: 0; +} + +.BehaviorKeys__Projection__Crumb:not(:disabled):hover { + color: var(--theme-blue); +} + +.BehaviorKeys__Projection__Crumb:disabled { + cursor: default; +} diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.tsx new file mode 100644 index 0000000..9f2d955 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysProjection.tsx @@ -0,0 +1,161 @@ +import React, { + FC, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { IconChevronRight } from "../Icon/ChevronRight"; +import { Projection } from "./types"; +import { useResizeObserver } from "../../hooks/useResizeObserver/useResizeObserver"; + +import "./BehaviorKeysProjection.scss"; + +const getContentWidth = (element: HTMLDivElement): number => { + const styles = getComputedStyle(element); + + return ( + element.clientWidth - + parseFloat(styles.paddingLeft) - + parseFloat(styles.paddingRight) + ); +}; + +export const BehaviorKeysProjection: FC<{ + projection: Projection; + onProjectionChange: (newProjection: Projection) => void; +}> = ({ projection, onProjectionChange }) => { + const projectionRef = useRef(null); + + const defaultDisplayState = { + index: -Infinity, + projection, + toCalculate: true, + }; + const [displayState, setDisplayState] = useState(defaultDisplayState); + + if (displayState.projection !== projection) { + setDisplayState(defaultDisplayState); + } + + const calculateDisplayState = () => { + if (projectionRef.current && displayState.toCalculate) { + const [header, collapsed, ...crumbs] = Array.from( + projectionRef.current.children + ) as HTMLElement[]; + + if (crumbs.length === 0) { + setDisplayState((prevDisplayState) => ({ + ...prevDisplayState, + idx: 0, + toCalculate: false, + })); + } else { + collapsed.style.display = ""; + + const collapsedWidth = collapsed.offsetWidth; + collapsed.style.display = "none"; + + const width = getContentWidth(projectionRef.current); + const availableWidth = width - header.offsetWidth; + + collapsed.style.display = ""; + + const { idx } = crumbs.reduceRight( + (prev, node, idx) => { + if (!prev.done) { + const nodeWidth = node.offsetWidth; + const nextAvailableWidth = prev.width - nodeWidth; + const threshold = idx === 0 ? 0 : collapsedWidth; + + if (nextAvailableWidth <= threshold) { + return { ...prev, done: true }; + } + + return { width: nextAvailableWidth, idx, done: false }; + } + + return prev; + }, + { width: availableWidth, idx: Infinity, done: false } + ); + + if (idx < Infinity) { + setDisplayState((prevDisplayState) => ({ + ...prevDisplayState, + index: idx, + toCalculate: false, + })); + } + } + } + }; + + const calculateDisplayStateRef = useRef(calculateDisplayState); + + useLayoutEffect(() => { + calculateDisplayStateRef.current = calculateDisplayState; + calculateDisplayStateRef.current(); + }); + + const setResizeObserver = useResizeObserver(() => { + setDisplayState(defaultDisplayState); + }); + + const mergedRef = useCallback( + (node: HTMLDivElement | null) => { + projectionRef.current = node; + setResizeObserver(node); + }, + [setResizeObserver] + ); + + return ( +
    + + {displayState.index > 0 || displayState.toCalculate ? ( + + ) : null} + {projection.map(({ label }, idx) => { + if (idx < displayState.index) return null; + return ( + + ); + })} +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.scss b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.scss new file mode 100644 index 0000000..8cdf016 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.scss @@ -0,0 +1,10 @@ +.BehaviorKeysRow { + padding: 0 var(--padding-horizontal); + width: 100%; + box-sizing: border-box; + display: flex; + + &:not(:last-child) { + margin-bottom: var(--bk-row-spacing); + } +} diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.tsx b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.tsx new file mode 100644 index 0000000..583b265 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/BehaviorKeysRow.tsx @@ -0,0 +1,25 @@ +import React, { FC, HTMLProps } from "react"; +import classNames from "classnames"; +import omit from "lodash/omit"; + +import "./BehaviorKeysRow.scss"; + +export const BehaviorKeysRow: FC< + DistributiveOmit< + | ({ as: "li" } & Omit, "as">) + | ({ as: "div" } & Omit, "as">), + "ref" + > +> = ({ className, ...props }) => { + const extraProps = { + className: classNames("BehaviorKeysRow", className), + }; + + if (props.as === "li") { + return
  • ; + } else if (props.as === "div") { + return
    ; + } + + throw new Error("Unrecognised tag type"); +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/project.ts b/apps/sim-core/packages/core/src/components/BehaviorKeys/project.ts new file mode 100644 index 0000000..7877d45 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/project.ts @@ -0,0 +1,37 @@ +import produce from "immer"; + +import { + BehaviorKeysDraftFieldWithRows, + BehaviorKeysDraftRow, + fieldHasRows, +} from "../../features/files/behaviorKeys"; +import { Projection } from "./types"; + +export const reduceProjection = ( + projection: Projection, + data: BehaviorKeysDraftFieldWithRows +): BehaviorKeysDraftFieldWithRows => + projection.reduce((data, { idx }) => { + if (!fieldHasRows(data)) { + throw new Error("Invalid projection"); + } + + const projected = data.rows[idx][1]; + + if (!fieldHasRows(projected)) { + throw new Error("Invalid projection"); + } + + return projected; + }, data); + +export const assignProjection = ( + projection: Projection, + data: T, + rows: BehaviorKeysDraftRow[] +): T => + produce(data, (fullDraft) => { + const draft = reduceProjection(projection, fullDraft); + + draft.rows = rows; + }); diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/types.ts b/apps/sim-core/packages/core/src/components/BehaviorKeys/types.ts new file mode 100644 index 0000000..c99856e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/types.ts @@ -0,0 +1,32 @@ +import { Draft } from "immer"; + +import { BehaviorKeysDraftField } from "../../features/files/behaviorKeys"; + +export type ProjectionItem = { + label: string; + idx: number; +}; + +export type Projection = ProjectionItem[]; + +export type BehaviorKeysFieldFormProps = { + fieldName: string; + clash: boolean; + projection: ProjectionItem[]; + onRowChange: ( + handler: ( + draft: Draft + ) => void | BehaviorKeysDraftField + ) => void; + onProject: () => void; + onRemove: () => void; + onAddField: () => void; + canModifyFields: boolean; + canRemoveField: boolean; + row: BehaviorKeysDraftField; + onNameChange: (name: string) => void; + onNameCommit: VoidFunction; + disabled: boolean; + typeDisabled: boolean; + emptyName: boolean; +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/utils.ts b/apps/sim-core/packages/core/src/components/BehaviorKeys/utils.ts new file mode 100644 index 0000000..250c4c4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/utils.ts @@ -0,0 +1,86 @@ +import { Draft, current } from "immer"; + +import { + BehaviorKeysDraftField, + BehaviorKeysDraftRow, + BehaviorKeysField, + toDraftFormat, + toDraftFormatPerField, +} from "../../features/files/behaviorKeys"; +import { + behaviorKeysRowTemplate, + behaviorKeysTopLevelRowTemplate, +} from "../../features/files/utils"; +import { nextNonClashingName } from "../../util/nextNonClashingName"; + +export const addField = ( + draft: Draft, + atRoot: boolean +) => { + const allocatedName = nextNonClashingName("field", [ + "field", + ...draft.map(([name]) => name), + ]); + + draft.push( + toDraftFormatPerField([ + allocatedName, + atRoot ? behaviorKeysTopLevelRowTemplate : behaviorKeysRowTemplate, + ]) + ); +}; + +export const castField = ( + draft: Draft, + nextType: BehaviorKeysField["type"] +) => { + const prevMeta = current(draft.meta); + const sharedMeta = { nullable: prevMeta.nullable }; + + switch (nextType) { + case "struct": + if (draft.key !== "fields") { + Object.assign(draft, { + key: "fields", + rows: [], + }); + } + draft.meta = { + ...sharedMeta, + type: "struct", + }; + break; + + case "fixed_size_list": + case "list": + if (draft.key !== "child") { + Object.assign(draft, { + key: "child", + rows: [toDraftFormatPerField(["child", behaviorKeysRowTemplate])], + }); + } + + if (nextType === "fixed_size_list") { + draft.meta = { + ...sharedMeta, + type: nextType, + length: prevMeta.type === "fixed_size_list" ? prevMeta.length : 1, + }; + } else { + draft.meta = { + ...sharedMeta, + type: nextType, + }; + } + break; + + default: + return { + ...toDraftFormat({ + ...sharedMeta, + type: nextType, + }), + uuid: draft.uuid, + }; + } +}; diff --git a/apps/sim-core/packages/core/src/components/BehaviorKeys/validate.ts b/apps/sim-core/packages/core/src/components/BehaviorKeys/validate.ts new file mode 100644 index 0000000..5927bf0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/BehaviorKeys/validate.ts @@ -0,0 +1,2 @@ +export const validateClash = (clash: boolean) => + clash ? "Field names must be unique." : null; diff --git a/apps/sim-core/packages/core/src/components/DataLoader/DataLoader.tsx b/apps/sim-core/packages/core/src/components/DataLoader/DataLoader.tsx new file mode 100644 index 0000000..1b0c788 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataLoader/DataLoader.tsx @@ -0,0 +1,132 @@ +import React, { + Dispatch, + FC, + ReactPortal, + SetStateAction, + useEffect, + useMemo, +} from "react"; + +import { DataTable } from "../DataTable"; +import type { EditorInstance } from "../TabbedEditor/types"; +import type { HcDatasetFile } from "../../features/files/types"; +import { TabbedEditorPanel } from "../TabbedEditor/Panel"; +// TODO: @mysterycommand - figure out how to mock "@hashintel/engine-web" +import { getTextModelRequired } from "../../features/monaco"; +import { isValidDataTable, isValidHeadings, isValidRecords } from "./utils"; +import { loadingMessage, successMessage, useDataLoaderParser } from "./hooks"; +import { useRemSize } from "../../hooks/useRemSize"; + +// Heights for calculating number of rows +// box-sizing: border-box means we don't need to calculate for those elements +const headingHeightRem = 1.875; +const paginationPaddingRem = 0.75 * 2; +const paginationPx = 26; +const rowHeightRem = 1.2; +const rowBorderPx = 1; + +type DataLoaderProps = { + url: string; + editorInstance: EditorInstance | undefined; + manifestId: string | null; + file: HcDatasetFile; + setDidFallback: Dispatch>; + containerHeight?: number; +}; + +export const DataLoader: FC = ({ + url, + editorInstance, + manifestId, + file, + setDidFallback, + containerHeight, +}) => { + const [remSize, remPortal] = useRemSize() as [number, ReactPortal]; + + /** + * grabbing the whole state object here to have a single, "equal by reference" + * check in the hook below + */ + const dataLoaderParserState = useDataLoaderParser(url, file); + + useEffect(() => { + /** + * we want to fallback if we're *not* loading (fallback while loading would + * cause a flicker of stale Monaco content), and if have *in*valid headings + * and records + */ + const shouldFallback = + dataLoaderParserState.message === successMessage && + !isValidDataTable( + dataLoaderParserState.headings, + dataLoaderParserState.records + ); + + setDidFallback(shouldFallback); + }, [dataLoaderParserState, setDidFallback]); + + /** + * destructuring here because we wind up using these interior values in the + * deciding what to render + */ + const { headings, records, contents, message } = dataLoaderParserState; + const model = getTextModelRequired(file, manifestId); + + useEffect(() => { + if (contents) { + model.setValue(contents); + } + }, [contents, model]); + + const numRows = useMemo( + () => + containerHeight + ? // divide container height by row height, taking heading height + // and pagination height into account + Math.trunc( + (containerHeight - + (remSize * (headingHeightRem + paginationPaddingRem) + + paginationPx)) / + (remSize * rowHeightRem + rowBorderPx) + ) + : // No container height? Don't render yet + undefined, + [containerHeight, remSize] + ); + + /** + * if we're still loading bail early, this should probably re-use the + * loader/spinner that we show in the viewer while Pyodide is loading + */ + if (message === loadingMessage) { + return
    {message}
    ; + } + + if (isValidHeadings(headings) && isValidRecords(records)) { + return ( + <> + + {remPortal} + + ); + } + + /** + * no `headings` and `records`, but we do have `contents`? fall back to Monaco + */ + if (contents) { + return ( + + ); + } + + /** + * at this point `message` represents some kind of an error message + */ + return
    {message}
    ; +}; diff --git a/apps/sim-core/packages/core/src/components/DataLoader/hooks/index.ts b/apps/sim-core/packages/core/src/components/DataLoader/hooks/index.ts new file mode 100644 index 0000000..51f739b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataLoader/hooks/index.ts @@ -0,0 +1,5 @@ +export { + loadingMessage, + successMessage, + useDataLoaderParser, +} from "./useDataLoaderParser"; diff --git a/apps/sim-core/packages/core/src/components/DataLoader/hooks/useDataLoaderParser.ts b/apps/sim-core/packages/core/src/components/DataLoader/hooks/useDataLoaderParser.ts new file mode 100644 index 0000000..9ca82fb --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataLoader/hooks/useDataLoaderParser.ts @@ -0,0 +1,142 @@ +import { useEffect, useReducer } from "react"; +import { + DatasetFormat, + DatasetRequestError, + fetchDataset, + parseDatasetUrl, +} from "@hashintel/utils/lib/datasets/fetchDataset"; + +import type { DataLoaderParserReducer, DataLoaderParserState } from "../types"; +import { HcDatasetFile } from "../../../features/files/types"; +import { jsonToRows } from "../utils"; + +export const loadingMessage = "Loading..."; +export const successMessage = "Success!"; + +const dataLoaderParserReducer: DataLoaderParserReducer = (state, action) => { + switch (action.type) { + case "success": + return { + ...action.payload, + message: successMessage, + }; + case "invalidUrl": + return { + message: [ + `There was a problem parsing ${action.payload.url}`, + `Parsing failed with: ${action.payload.errorMessage}`, + ].join("\n"), + }; + case "unparseableValue": + return { + message: [ + `There was a problem parsing "${action.payload.pathname}"`, + `The server responded with an unparseable value.`, + `Parsing failed with: ${action.payload.errorMessage}`, + ].join("\n"), + }; + case "unsupportedExtension": + return { + message: [ + `There was a problem parsing "${action.payload.pathname}"`, + `The extension "${action.payload.ext}" is not currently displayable.`, + ].join("\n"), + }; + case "loadingError": + return { + message: [ + `There was a problem loading "${action.payload.pathname}"`, + `The server responded with: ${action.payload.errorMessage}`, + ].join("\n"), + }; + default: + return state; + } +}; + +export const useDataLoaderParser = ( + url: string, + file: HcDatasetFile +): DataLoaderParserState => { + const [state, dispatch] = useReducer(dataLoaderParserReducer, { + message: loadingMessage, + }); + const { rawCsv } = file.data; + + useEffect(() => { + let pathname: string; + let format: DatasetFormat; + + try { + const result = parseDatasetUrl(url, rawCsv); + + if (result.format === null) { + dispatch({ + type: "unsupportedExtension", + payload: { + pathname: result.pathname, + ext: result.ext, + }, + }); + return; + } + + ({ pathname, format } = result); + } catch (error) { + dispatch({ + type: "invalidUrl", + payload: { url, errorMessage: error.message }, + }); + return; + } + + const controller = new AbortController(); + + fetchDataset(url, format, file.data.inPlaceData, controller.signal) + .then((result) => { + if (typeof result === "string") { + /** + * fetchDataset only returns string when result is not parseable – + * but it captures the error. Let's trigger the parse error. + * + * @todo make this not necessary + */ + JSON.parse(result); + } else { + dispatch({ + type: "success", + payload: jsonToRows(result, format), + }); + } + }) + .catch((err) => { + if (err.name === "AbortError") { + return; + } + + console.error(err); + + if (err instanceof DatasetRequestError) { + dispatch({ + type: "loadingError", + payload: { + pathname, + errorMessage: err.message, + }, + }); + } else { + dispatch({ + type: "unparseableValue", + payload: { + pathname, + errorMessage: err.message, + }, + }); + } + }); + + return () => controller.abort(); + }, [rawCsv, url]); + + return state; +}; diff --git a/apps/sim-core/packages/core/src/components/DataLoader/types.ts b/apps/sim-core/packages/core/src/components/DataLoader/types.ts new file mode 100644 index 0000000..159a7b3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataLoader/types.ts @@ -0,0 +1,51 @@ +import { Reducer } from "react"; + +type DataLoaderParserMessage = { + message: string; +}; + +type DataLoaderParserData = { + headings?: string[]; + records?: any[][]; + contents?: string; +}; + +export type DataLoaderParserState = DataLoaderParserData & + DataLoaderParserMessage; + +type DataLoaderParserActionSuccess = { + type: "success"; + payload: DataLoaderParserData; +}; + +type DataLoaderParserActionInvalidUrl = { + type: "invalidUrl"; + payload: { url: string; errorMessage: string }; +}; + +type DataLoaderParserActionUnparseableValue = { + type: "unparseableValue"; + payload: { pathname: string; errorMessage: string }; +}; + +type DataLoaderParserActionUnsupportedExtension = { + type: "unsupportedExtension"; + payload: { pathname: string; ext: string }; +}; + +type DataLoaderParserActionLoadingError = { + type: "loadingError"; + payload: { pathname: string; errorMessage: string }; +}; + +type DataLoaderParserAction = + | DataLoaderParserActionSuccess + | DataLoaderParserActionInvalidUrl + | DataLoaderParserActionUnparseableValue + | DataLoaderParserActionUnsupportedExtension + | DataLoaderParserActionLoadingError; + +export type DataLoaderParserReducer = Reducer< + DataLoaderParserState, + DataLoaderParserAction +>; diff --git a/apps/sim-core/packages/core/src/components/DataLoader/utils.ts b/apps/sim-core/packages/core/src/components/DataLoader/utils.ts new file mode 100644 index 0000000..d6ac95f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataLoader/utils.ts @@ -0,0 +1,117 @@ +/** + * headings is valid if it's not undefined, and is an array of strings + */ + +import { DatasetFormat } from "@hashintel/utils/lib/datasets/fetchDataset"; + +export const isValidHeadings = ( + headings: string[] | undefined +): headings is string[] => + !!( + headings && + Array.isArray(headings) && + headings.every((heading) => typeof heading === "string") + ); + +/** + * records is valid is valid if it's not undefined, and is an array, if we + * really wanted to go nuts with validation here we could also validate each + * record too: + * + * ``` + * records.every(record => + * Array.isArray(record) && + * record.every(cell => typeof cell === 'string') + * ) + * ``` + */ +export const isValidRecords = ( + records: any[][] | undefined +): records is any[][] => + !!(records && Array.isArray(records) && Array.isArray(records[0])); + +/** + * we have a valid data table if we have valid `headings` and `records` + */ +export const isValidDataTable = ( + headings: string[] | undefined, + records: any[][] | undefined +): boolean => isValidHeadings(headings) && isValidRecords(records); + +const toDatasetJson = (json: any) => JSON.stringify(json, null, 2); + +export const getHeadingsRecordsForJsonObjects = ( + json: any[] +): [string[], any[]] => { + if (json.length === 0) { + return [[], []]; + } + + /** + * `headings` is a `Set` of all the keys of all the objects in the json, the + * json is presumed to be an array of objects + */ + const headings = [ + ...json.reduce((acc: Set, record: any) => { + Object.keys(record).forEach((key) => acc.add(key)); + return acc; + }, new Set()), + ]; + + /** + * when a record doesn't have a value for some key/heading (or doesn't have + * that key at all) we need some kind of fallback + */ + const defaults = headings.reduce((acc, heading) => { + acc[heading] = ""; + return acc; + }, {} as any); + + /** + * map each object's values into an array whose order matches the headings + * array, and with a default for nullish values/missing keys + */ + const records = json.map((record) => { + return headings.map((heading) => record[heading] ?? defaults[heading]); + }); + + return [headings, records]; +}; + +export const jsonToRows = (json: any[], format: DatasetFormat) => { + const contents = toDatasetJson(json); + + if (format === DatasetFormat.Json) { + let headings: string[] | undefined = undefined; + let records: any[][] | undefined = undefined; + + try { + if (json.every((row) => typeof row === "object")) { + const [hs, rs] = getHeadingsRecordsForJsonObjects(json); + headings = hs; + records = rs; + } + } catch { + /** + * if we fail here we have valid json, but it's not an array of + * objects that we know how to turn in to headings and records, + * so we want to silently fail this part and "fallback" + */ + } + + return { + contents, + headings, + records, + }; + } else { + const contents = toDatasetJson(json); + const headings = json.shift(); + + return { + contents, + headings, + records: json, + }; + } +}; diff --git a/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.css b/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.css new file mode 100644 index 0000000..64d7ea0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.css @@ -0,0 +1,12 @@ +.DataTableBody > tr { + background: var(--theme-dark); + border-bottom: 1px solid var(--theme-border); +} + +.DataTableBody > tr:last-child { + border-bottom: none; +} + +.DataTableBody > tr:nth-child(even) { + background: var(--theme-darkest); +} diff --git a/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.spec.tsx b/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.spec.tsx new file mode 100644 index 0000000..37b8399 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DataTableBody } from "./DataTableBody"; + +it("renders without crashing", () => { + const table = document.createElement("table"); + ReactDOM.render(, table); + ReactDOM.unmountComponentAtNode(table); +}); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.tsx b/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.tsx new file mode 100644 index 0000000..0f1863d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Body/DataTableBody.tsx @@ -0,0 +1,24 @@ +import React, { FC, memo } from "react"; + +import { DataTableRow } from ".."; + +import "./DataTableBody.css"; + +type DataTableBodyProps = { + beginIndex: number; + records: any[][]; +}; + +export const DataTableBody: FC = memo( + ({ beginIndex, records }) => ( + + {records.map((record, idx) => ( + + ))} + + ) +); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Body/index.ts b/apps/sim-core/packages/core/src/components/DataTable/Body/index.ts new file mode 100644 index 0000000..62e6225 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Body/index.ts @@ -0,0 +1 @@ +export { DataTableBody } from "./DataTableBody"; diff --git a/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.css b/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.css new file mode 100644 index 0000000..805acbd --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.css @@ -0,0 +1,42 @@ +.DataTableCell { + height: 1.2rem; + line-height: 0.8rem; + font-size: 0.8rem; + padding: 0.2rem 1rem; + box-sizing: border-box; + border-right: 1px solid var(--theme-border); +} + +.DataTableCell:last-child { + border-right: none; +} + +.DataTableCell svg { + fill: currentColor; +} + +.DataTableCell-parsedIndex, +.DataTableCell-rgb, +.DataTableCell-boolean { + text-align: center; +} + +.DataTableCell-rgb > span { + display: inline-block; + text-indent: 100%; + overflow: hidden; + width: 1em; + height: 1em; + border-radius: 2px; +} + +.DataTableCell-string > span, +.overflowable-string { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.empty-value { + opacity: 0.4; +} diff --git a/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.spec.tsx b/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.spec.tsx new file mode 100644 index 0000000..930915b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DataTableCell } from "./DataTableCell"; + +it("renders without crashing", () => { + const tr = document.createElement("tr"); + ReactDOM.render(, tr); + ReactDOM.unmountComponentAtNode(tr); +}); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.tsx b/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.tsx new file mode 100644 index 0000000..2855f4f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Cell/DataTableCell.tsx @@ -0,0 +1,88 @@ +import React, { FC, memo, ReactNode } from "react"; +import classNames from "classnames"; + +import { IconCheck, IconClose } from "../../Icon"; + +import "./DataTableCell.css"; + +type DataTableCellProps = { + cellValue: any; +}; + +enum TypeOf { + Undefined = "undefined", + Object = "object", + Boolean = "boolean", + Number = "number", + BigInt = "bigint", + String = "string", + Symbol = "symbol", + Function = "function", +} + +const cell: { [type in TypeOf]: (value: any) => ReactNode } = { + [TypeOf.Undefined]: () => -, + [TypeOf.Object]: (value) => { + if (value === null) { + return -; + } + + if (Array.isArray(value)) { + if (value.every((val) => typeof val === "string")) { + return ( + + {value.join(", ")} + + ); + } + + // this makes the somewhat *bold* assumption that any array of 3 numbers + // between 0 and 255 is an rgb value + if ( + value.length === 3 && + value.every((val) => typeof val === "number" && 0 <= val && val <= 255) + ) { + return ( + + {value.join(", ")} + + ); + } + } + + return ( +
    +        {JSON.stringify(value)}
    +      
    + ); + }, + [TypeOf.Boolean]: (value) => + value ? : , + [TypeOf.Number]: (value) => value, + [TypeOf.String]: (value) => ( + + {value || "-"} + + ), + // currently unhandled, but left in for completeness sake, at time of writing + // this cell only deals with JSON.parse-able values and I don't think these + // types can be extracted from a JSON string + [TypeOf.BigInt]: () => null, + [TypeOf.Symbol]: () => null, + [TypeOf.Function]: () => null, +}; + +export const DataTableCell: FC = memo( + ({ cellValue: value }) => ( + + {cell[typeof value](value)} + + ) +); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Cell/index.ts b/apps/sim-core/packages/core/src/components/DataTable/Cell/index.ts new file mode 100644 index 0000000..545e2b5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Cell/index.ts @@ -0,0 +1 @@ +export { DataTableCell } from "./DataTableCell"; diff --git a/apps/sim-core/packages/core/src/components/DataTable/DataTable.css b/apps/sim-core/packages/core/src/components/DataTable/DataTable.css new file mode 100644 index 0000000..9300d16 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/DataTable.css @@ -0,0 +1,12 @@ +.DataTable__container { + height: 100%; + overflow-y: hidden; + overflow-x: auto; +} + +.DataTable { + border-collapse: collapse; + border-spacing: 0; + + min-width: 100%; +} diff --git a/apps/sim-core/packages/core/src/components/DataTable/DataTable.spec.tsx b/apps/sim-core/packages/core/src/components/DataTable/DataTable.spec.tsx new file mode 100644 index 0000000..7831bf1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/DataTable.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DataTable } from "./DataTable"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/DataTable/DataTable.tsx b/apps/sim-core/packages/core/src/components/DataTable/DataTable.tsx new file mode 100644 index 0000000..e8e9742 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/DataTable.tsx @@ -0,0 +1,42 @@ +import React, { FC, memo, useState } from "react"; + +import { DataTableBody, DataTableHead, DataTablePagination } from "."; + +import "./DataTable.css"; + +type DataTableProps = { + headings: string[]; + records: any[][]; + recordsPerPage?: number; +}; + +export const DataTable: FC = memo( + ({ headings, records, recordsPerPage = 50 }) => { + const [currentPage, setCurrentPage] = useState(0); + const totalPages = Math.ceil(records.length / recordsPerPage); + + return ( +
    +
    + + + +
    +
    + {totalPages > 1 && ( + + )} +
    + ); + } +); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.css b/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.css new file mode 100644 index 0000000..6ab0c0e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.css @@ -0,0 +1,13 @@ +.DataTableHead th { + height: 1.875rem; + font-size: 0.875rem; + line-height: 0.875rem; + text-align: left; + padding: 0.5rem 1rem; + box-sizing: border-box; + white-space: nowrap; +} + +.DataTableRow > th:last-child { + border-right: none; +} diff --git a/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.spec.tsx b/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.spec.tsx new file mode 100644 index 0000000..a469777 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DataTableHead } from "./DataTableHead"; + +it("renders without crashing", () => { + const table = document.createElement("table"); + ReactDOM.render(, table); + ReactDOM.unmountComponentAtNode(table); +}); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.tsx b/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.tsx new file mode 100644 index 0000000..fe15b8a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Head/DataTableHead.tsx @@ -0,0 +1,29 @@ +import React, { FC, memo } from "react"; +import { kebabCase } from "lodash"; + +import "./DataTableHead.css"; + +type DataTableHeadProps = { + headings: string[]; +}; + +export const DataTableHead: FC = memo(({ headings }) => ( + <> + + + {headings.map((heading, idx) => ( + + ))} + + + + + {headings.map((heading, idx) => ( + + {heading} + + ))} + + + +)); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Head/index.ts b/apps/sim-core/packages/core/src/components/DataTable/Head/index.ts new file mode 100644 index 0000000..6f4ccdf --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Head/index.ts @@ -0,0 +1 @@ +export { DataTableHead } from "./DataTableHead"; diff --git a/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.css b/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.css new file mode 100644 index 0000000..34494d0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.css @@ -0,0 +1,33 @@ +.DataTablePagination { + background: var(--theme-black); + + box-sizing: border-box; + padding: 0.75rem; + + width: 100%; + position: sticky; + bottom: 0; + left: 0; + + display: flex; + align-items: center; + justify-content: space-between; + + opacity: 0.35; + transition: opacity 0.1s ease-in-out; +} + +.DataTablePagination:hover { + opacity: 1; +} + +.DataTablePagination > .Fancy { + padding: 0; + border-radius: 50%; + height: 26px; +} + +.DataTablePagination > .Fancy:disabled { + border-color: var(--theme-dark-border); + fill: var(--theme-dark-border); +} diff --git a/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.spec.tsx b/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.spec.tsx new file mode 100644 index 0000000..1aa6562 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.spec.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DataTablePagination } from "./DataTablePagination"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( + {}} + totalPages={1} + />, + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.tsx b/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.tsx new file mode 100644 index 0000000..930d85d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Pagination/DataTablePagination.tsx @@ -0,0 +1,35 @@ +import React, { FC, Dispatch, SetStateAction, memo } from "react"; + +import { FancyButton } from "../../Fancy"; + +import "./DataTablePagination.css"; + +type DataTablePaginationProps = { + currentPage: number; + setCurrentPage: Dispatch>; + totalPages: number; +}; + +export const DataTablePagination: FC = memo( + ({ currentPage, setCurrentPage, totalPages }) => ( +
    + + setCurrentPage(Math.min(Math.max(0, currentPage - 1), totalPages - 1)) + } + /> + {currentPage + 1} / {totalPages} + + setCurrentPage(Math.min(Math.max(0, currentPage + 1), totalPages - 1)) + } + /> +
    + ) +); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Pagination/index.ts b/apps/sim-core/packages/core/src/components/DataTable/Pagination/index.ts new file mode 100644 index 0000000..9dfe841 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Pagination/index.ts @@ -0,0 +1 @@ +export { DataTablePagination } from "./DataTablePagination"; diff --git a/apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.spec.tsx b/apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.spec.tsx new file mode 100644 index 0000000..2de326b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DataTableRow } from "./DataTableRow"; + +it("renders without crashing", () => { + const tbody = document.createElement("tbody"); + ReactDOM.render(, tbody); + ReactDOM.unmountComponentAtNode(tbody); +}); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.tsx b/apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.tsx new file mode 100644 index 0000000..824f8ee --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Row/DataTableRow.tsx @@ -0,0 +1,19 @@ +import React, { FC, memo } from "react"; + +import { DataTableCell } from "../Cell"; + +type DataTableRowProps = { + rowIndex: number; + record: any[]; +}; + +export const DataTableRow: FC = memo( + ({ rowIndex, record }) => ( + + + {record.map((value, idx) => ( + + ))} + + ) +); diff --git a/apps/sim-core/packages/core/src/components/DataTable/Row/index.ts b/apps/sim-core/packages/core/src/components/DataTable/Row/index.ts new file mode 100644 index 0000000..4358db5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/Row/index.ts @@ -0,0 +1 @@ +export { DataTableRow } from "./DataTableRow"; diff --git a/apps/sim-core/packages/core/src/components/DataTable/index.ts b/apps/sim-core/packages/core/src/components/DataTable/index.ts new file mode 100644 index 0000000..5d19038 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DataTable/index.ts @@ -0,0 +1,6 @@ +export { DataTable } from "./DataTable"; +export { DataTableHead } from "./Head"; +export { DataTableBody } from "./Body"; +export { DataTableRow } from "./Row"; +export { DataTableCell } from "./Cell"; +export { DataTablePagination } from "./Pagination"; diff --git a/apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.css b/apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.css new file mode 100644 index 0000000..610adf1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.css @@ -0,0 +1,26 @@ +.DiscordWidget { + position: fixed; + bottom: var(--discord-button-y-offset); + right: 20px; + height: var(--discord-button-size); + width: var(--discord-button-size); + z-index: 99998; + background: white; + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + cursor: pointer; + user-select: none; + transition: background-color 0.2s; + padding: 8px; + background: var(--theme-dark-grey); + box-sizing: border-box; +} + +.DiscordWidget--higher { + bottom: 58px; +} + +.DiscordWidget:hover { + background: #5865f2; + transition: background-color 0.3s; +} diff --git a/apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.tsx b/apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.tsx new file mode 100644 index 0000000..bcaa8ac --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DiscordWidget/DiscordWidget.tsx @@ -0,0 +1,76 @@ +import React, { FC } from "react"; +import { Provider, useSelector, useStore } from "react-redux"; +import { Store } from "@reduxjs/toolkit"; +import classNames from "classnames"; + +import { IconDiscord } from "../Icon"; +import { Link } from "../Link/Link"; +import { RootState } from "../../features/types"; +import { Scope, selectScope } from "../../features/scopes"; +import { store as appStore } from "../../features/store"; +import { selectActivityVisible } from "../../features/viewer/selectors"; + +import "./DiscordWidget.css"; + +export const DISCORD_URL = "https://discord.gg/BPMrGAhjPh"; + +/** + * @warning This component specifically does not rely on a Redux store being in + * context, as it can be rendered by ErrorBoundary outside of the Redux + * store provider. Do not use any features that rely on context – and + * this component must make every attempt to avoid/catch errors. + */ +export const BasicDiscordWidget: FC<{ + className?: string; + store?: Store; + errored?: boolean; +}> = ({ className, store = appStore, errored = false }) => { + let loggedIn = true; + + try { + loggedIn = selectScope[Scope.useAccount](store.getState()); + } catch {} + + const children = ; + const props = { className: classNames("DiscordWidget", className) }; + + if (!loggedIn) { + const url = "/signup"; + + if (errored) { + return ( + + {children} + + ); + } else { + return ( + + + {children} + + + ); + } + } + + return ( + + {children} + + ); +}; + +export const DiscordWidget: FC = () => { + const activityVisible = useSelector(selectActivityVisible); + const store = useStore(); + + return ( + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/DiscordWidget/index.ts b/apps/sim-core/packages/core/src/components/DiscordWidget/index.ts new file mode 100644 index 0000000..6f7a84b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/DiscordWidget/index.ts @@ -0,0 +1 @@ +export { DiscordWidget } from "./DiscordWidget"; diff --git a/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.scss b/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.scss new file mode 100644 index 0000000..7d89854 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.scss @@ -0,0 +1,222 @@ +/** + * n.b. this is *mostly* copy/pasted from the `hashintel/hash.ai` repo, + * specifically the below-linked component and styles + * + * @see: https://github.com/hashintel/hash.ai/blob/master/nextjs/src/components/Dropdown.tsx + * @see: https://github.com/hashintel/hash.ai/blob/master/nextjs/src/styles/scss/components/_ReactSelect.scss + */ + +.dropdown-wrapper { + width: 100%; + position: relative; +} + +.required-input-enforcement { + opacity: 0 !important; + height: 0 !important; + width: 0 !important; + position: absolute; + top: 42px; + left: 82px; +} + +div.react-select-container { + font-family: var(--font); + width: 100%; + + div.react-select__control { + color: var(--theme-dark); + border: var(--theme-border); + background-color: rgba(255, 255, 255, 0.1); + min-height: 48px; + transition: background-color 0.3s; + } + + div.react-select__control--is-focused { + box-shadow: 0px 5px 20px rgba(95, 71, 255, 0.05); + transition: box-shadow 0.3s; + background-color: white; + } + + div.react-select__control:hover { + background-color: white; + } +} + +div.react-select-container div.react-select__control--is-focused:hover { + background-color: white; +} + +div.react-select-container div.react_select__value-container { + padding: 0 17px; +} + +div.react-select-container.dark div.react-select__control { + border: var(--theme-border); + color: white; + background-color: rgba(255, 255, 255, 0.1); +} + +div.react-select-container.dark + div.react-select__control + div.react-select__placeholder { + color: #808080; +} + +div.react-select-container.dark + div.react-select__control + div.react-select__single-value { + color: white; +} + +div.react-select-container.dark + div.react-select__control + div.react-select__input { + color: white; +} + +div.react-select-container.dark div.react-select__control--is-focuesd { + -webkit-box-shadow: none; + box-shadow: none; +} + +div.react-select-container.dark div.react-select__multi-value { + background-color: black; +} + +div.react-select-container.dark div.react-select__multi-value__label { + color: white; +} + +div.react-select-container div.react-select__multi-value__label { + user-select: none; +} + +div.react-select-container.dark div.react-select__option { + color: var(--theme-white); +} + +div.react-select-container-noborder { + width: 100%; +} + +div.react-select-container-noborder div.react-select__control { + border: none; + background: none; +} + +div.react-select-container-noborder div.react-select__control--is-focused { + -webkit-box-shadow: none; + box-shadow: none; +} + +div.react-select-container-noborder div.react_select__value-container { + padding: 0 17px 0 0; +} + +div.react-select__control { + border-radius: var(--button-border-radius); + font-size: 14px; + line-height: 18px; + text-transform: none; + height: auto; + + -webkit-transition: all 0.2s; + -o-transition: all 0.2s; + transition: all 0.2s; +} + +div.react-select__control div.react-select__placeholder { + font-weight: 300; + padding-left: 2px; + color: rgba(27, 29, 36, 0.5); +} + +div.react-select__control div.react-select__single-value, +div.react-select__control div.react-select__placeholder { + white-space: nowrap; + text-overflow: ellipsis; + max-width: calc(100% - 24px); + overflow: hidden; +} + +div.react-select__menu { + z-index: 4; + font-size: 14px; +} + +div.react-select__value-container { + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + padding: 2px 8px 2px 16px; +} + +div.react-select__placeholder { + color: #e6e6e6; +} + +span.react-select__indicator-separator { + display: none; +} + +.react-select__indicators { + margin-right: 5px; +} + +.dropdown-wrapper svg { + fill: rgba(255, 255, 255, 0.33); +} + +div.react-select__multi-value__remove { + -webkit-transition: all 0.2s; + -o-transition: all 0.2s; + transition: all 0.2s; +} + +div.react-select__multi-value__remove:hover { + background: red; +} + +div.react-select__multi-value__remove:hover svg { + fill: white; + -webkit-transition: fill 0.2s; + -o-transition: fill 0.2s; + transition: fill 0.2s; +} + +div.react-select__input { + padding-left: 3px; +} + +div.react-select__input input { + width: 100% !important; + -webkit-box-shadow: none !important; + box-shadow: none !important; +} + +div.react-select__menu { + background-color: rgb(30, 30, 30); + border-color: rgb(80, 80, 80); + color: rgb(120, 120, 120); +} + +div.react-select__menu { + z-index: 4; + font-size: 14px; +} +div.react-select__menu .sub-label { + font-size: 11px; + color: #8c8c8c; + margin-top: 2px; + line-height: 1.3; +} + +div.react-select__menu div.react-select__option--is-selected .sub-label { + color: #e6e6e6; +} + +div.react-select__option--is-focused { + background-color: rgb(80, 80, 80); + color: rgb(160, 160, 160); +} diff --git a/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.spec.tsx b/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.spec.tsx new file mode 100644 index 0000000..97a9782 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.spec.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { Dropdown } from "./Dropdown"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( + {}} />, + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.tsx b/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..e5fb928 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,144 @@ +import React, { FC, useState, useEffect } from "react"; +import Creatable from "react-select/creatable"; +import Select, { createFilter } from "react-select"; +import classNames from "classnames"; + +import { DropdownMenuList } from "./MenuList"; +import type { DropdownProps, ReactSelectOption } from "./types"; +import { IconMenuDown } from "../Icon"; + +import "./Dropdown.scss"; + +const caseInsensitiveIsValidNewOption = ( + inputValue: string, + _selectValue: ReactSelectOption[], + selectOptions: ReactSelectOption[] +) => { + const exactValueExists = selectOptions.find((el) => el.value === inputValue); + // Without this, it will show create option for empty values. + const valueIsNotEmpty = inputValue.trim().length; + return !exactValueExists && valueIsNotEmpty; +}; + +/** + * n.b. this is *mostly* copy/pasted from the `hashintel/hash.ai` repo, + * specifically the below-linked component and styles + * + * @see: https://github.com/hashintel/hash.ai/blob/master/nextjs/src/components/Dropdown.tsx + * @see: https://github.com/hashintel/hash.ai/blob/master/nextjs/src/styles/scss/components/_ReactSelect.scss + */ + +export const Dropdown: FC = ({ + options, + value, + onChange, + onBlur, + isSearchable = true, + isClearable = false, + isMulti, + isOptionDisabled, + name, + noOptionsMessage, + creatable, + placeholder, + id, + dark, + required, + isDisabled, + menuIsOpen, + components, + largeList = false, + className = "", + creatableIsCaseInsensitive = false, +}) => { + const [liveOptions, setOptions] = useState(options); + + useEffect(() => { + setOptions(options); + }, [options]); + + // this will prioritise matches which begin with the search string. + // the default behaviour we are overriding is to sort alphabetically. + const handleSearchInput = (inputValue: string, { action }: any) => { + if (action === "input-change") { + let foundOptions = options.filter((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + if (inputValue !== "") { + foundOptions = foundOptions.sort((a, b) => { + if (a.label.toLowerCase() === inputValue.toLowerCase()) { + return -1; + } else if (b.label.toLowerCase() === inputValue.toLowerCase()) { + return 1; + } else if ( + a.label.toLowerCase().startsWith(inputValue.toLowerCase()) + ) { + return -1; + } else if ( + b.label.toLowerCase().startsWith(inputValue.toLowerCase()) + ) { + return 1; + } else { + return a.label.localeCompare(b.label); + } + }); + } + setOptions(foundOptions); + } else if (action === "menu-close") { + setOptions(options); + } + }; + + const props: any = { + options: liveOptions, + value, + onChange: (option: ReactSelectOption) => { + onChange(option); + setOptions(options); + }, + onBlur, + components: { + DropdownIndicator: IconMenuDown, + ...components, + ...(largeList ? { MenuList: DropdownMenuList } : {}), + }, + isMulti, + isClearable, + isSearchable, + isOptionDisabled, + name, + noOptionsMessage: () => noOptionsMessage, + placeholder, + classNamePrefix: "react-select", + className: classNames("react-select-container", className, { dark }), + inputId: id, + isDisabled, + menuIsOpen, + }; + if (creatableIsCaseInsensitive) { + props.filterOption = createFilter({ ignoreCase: false }); + props.isValidNewOption = caseInsensitiveIsValidNewOption; + } + + // only bother to override filtering behaviour if we are allowing filtering + if (isSearchable) { + props.onInputChange = handleSearchInput; + props.filterOption = () => true; + } + + return ( +
    + {creatable ? : null} + autoComplete="off" + className={classNames("required-input-enforcement", className)} + required + /> + )} +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.spec.tsx b/apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.spec.tsx new file mode 100644 index 0000000..293754b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { DropdownMenuList } from "./DropdownMenuList"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.tsx b/apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.tsx new file mode 100644 index 0000000..5bca77d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/MenuList/DropdownMenuList.tsx @@ -0,0 +1,61 @@ +import React, { FC, useRef, useEffect, Children } from "react"; +import { VariableSizeList } from "react-window"; + +import type { ReactSelectOption } from "../types"; + +type DropdownMenuListProps = { + options: ReactSelectOption[]; +}; + +/** + * these numbers are kinda magic numbers, it's known and tolerated for + * expediency's sake, the occur here and in the hash.ai repo + * + * @see https://github.com/hashintel/internal/issues/780 + * @see https://github.com/hashintel/hash.ai/blob/master/src/components/Dropdown.tsx#L82-L88 + */ +const SUB_LABEL_MIN_SIZE = 36; +const SUB_LABEL_AVG_LENGTH = 55; +const SUB_LABEL_AVG_SIZE = 46; +const SUB_LABEL_MAX_SIZE = 60; +const LIST_HEIGHT = 200; + +export const DropdownMenuList: FC = ({ + options, + children, +}) => { + const listRef = useRef(null); + + useEffect(() => { + if (!listRef.current) { + return; + } + + listRef.current.resetAfterIndex(0, true); + }, [children, Children.count(children)]); + + const calculateSize = (idx: number) => { + const subLabel = options[idx]?.subLabel; + + return !subLabel || subLabel.length === 0 + ? SUB_LABEL_MIN_SIZE + : subLabel.length < SUB_LABEL_AVG_LENGTH + ? SUB_LABEL_AVG_SIZE + : SUB_LABEL_MAX_SIZE; + }; + + return ( + + {({ index, style }) => ( +
    {Children.toArray(children)[index]}
    + )} +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Dropdown/MenuList/index.ts b/apps/sim-core/packages/core/src/components/Dropdown/MenuList/index.ts new file mode 100644 index 0000000..2cb5024 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/MenuList/index.ts @@ -0,0 +1 @@ +export { DropdownMenuList } from "./DropdownMenuList"; diff --git a/apps/sim-core/packages/core/src/components/Dropdown/index.ts b/apps/sim-core/packages/core/src/components/Dropdown/index.ts new file mode 100644 index 0000000..2b088e3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/index.ts @@ -0,0 +1,2 @@ +export { Dropdown } from "./Dropdown"; +export { DropdownMenuList } from "./MenuList"; diff --git a/apps/sim-core/packages/core/src/components/Dropdown/types.ts b/apps/sim-core/packages/core/src/components/Dropdown/types.ts new file mode 100644 index 0000000..6365ba0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Dropdown/types.ts @@ -0,0 +1,31 @@ +import { SelectComponents } from "react-select/src/components"; + +export type ReactSelectOption = { + label: string; + subLabel?: string; + value: string; +}; + +export type DropdownProps = { + options: ReactSelectOption[]; + value: ReactSelectOption | ReactSelectOption[] | undefined; + onChange: (option: any) => void; + onBlur?: () => void; + isSearchable?: boolean; + isClearable?: boolean; + isMulti?: boolean; + isOptionDisabled?: (option: ReactSelectOption) => boolean; + name?: string; + noOptionsMessage?: string; + creatable?: boolean; + placeholder?: string; + id?: string; + dark?: boolean; + required?: boolean; + isDisabled?: boolean; + menuIsOpen?: boolean; + components?: Partial, "Option">>; + largeList?: boolean; + className?: string; + creatableIsCaseInsensitive?: boolean; +}; diff --git a/apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.scss b/apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.tsx b/apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.tsx new file mode 100644 index 0000000..899cb6f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/EmbedApp/EmbedApp.tsx @@ -0,0 +1,26 @@ +import React, { FC } from "react"; +import { useSelector } from "react-redux"; + +import { HashCoreAccessGate } from "../HashCore/AccessGate/HashCoreAccessGate"; +import { HashCoreSection } from "../HashCore/Section/HashCoreSection"; +import { + selectAccessGate, + selectProjectLoaded, +} from "../../features/project/selectors"; + +import "./EmbedApp.scss"; + +export const EmbedApp: FC = () => { + const projectLoaded = useSelector(selectProjectLoaded); + const accessGate = useSelector(selectAccessGate); + + if (accessGate) { + return ; + } + + if (!projectLoaded) { + return null; + } + + return ; +}; diff --git a/apps/sim-core/packages/core/src/components/EmbedApp/bootEmbed.tsx b/apps/sim-core/packages/core/src/components/EmbedApp/bootEmbed.tsx new file mode 100644 index 0000000..ba5b6d0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/EmbedApp/bootEmbed.tsx @@ -0,0 +1,62 @@ +/** + * Needs to be here or webpack breaks due to circular dependencies… + * + * @todo figure out how to remove this + */ +import "../../util/api"; +/** + * This is lazily loaded by HashCoreViewer when its needed, but that's a shared + * file so we can't boot the request early from there – this will do that for us + */ +import "../OpenInCore/OpenInCore"; + +import React from "react"; +import { render } from "react-dom"; + +import { App } from "../App"; +import { BasicUser } from "../../util/api/types"; +import { EmbedApp } from "./EmbedApp"; +import { RemoteSimulationProject } from "../../features/project/types"; +import { ValidatedEmbedParams } from "../../util/getEmbedParams"; +import { activateEmbedded } from "../../features/viewer/slice"; +import { boot } from "../../boot"; +import { fetchProject } from "../../features/project/slice"; +import { getUiQueryParams } from "../../hooks/useParameterisedUi"; +import { setBasicUser } from "../../features/user/slice"; +import { store } from "../../features/store"; + +// @todo error handling +export const bootEmbed = async ( + params: ValidatedEmbedParams, + prefetchedProjectPromise: Promise, + basicUserPromise: Promise +) => { + await boot(false); + + const { tabs, view } = getUiQueryParams(); + + store.dispatch(activateEmbedded({ tabs, tab: view })); + + await Promise.all([ + store.dispatch( + fetchProject({ + project: { pathWithNamespace: params.project, ref: params.ref }, + prefetchedRemoteProject: prefetchedProjectPromise, + redirect: false, + access: params.access, + }) + ), + basicUserPromise.then((basicUser) => { + if (basicUser) { + return store.dispatch(setBasicUser(basicUser)); + } + }), + ]); + + render( + + + , + document.getElementById("root") + ); +}; diff --git a/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.css b/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.css new file mode 100644 index 0000000..57cd675 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.css @@ -0,0 +1,31 @@ +.ErrorBoundary { + max-width: 600px; +} + +.ErrorBoundary__Header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-start: 0.83em; + margin-block-end: 0.83em; + margin-inline-start: 0; + margin-inline-end: 0; +} + +.ErrorBoundary__Header h2 { + margin: 0; +} + +.ErrorBoundary__Header__EventId { + font-size: 0.8em; + padding: 4px 8px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.1); + font-weight: bold; + white-space: nowrap; +} + +.ErrorBoundary__Footer { + display: flex; + justify-content: space-between; +} diff --git a/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.spec.tsx b/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.spec.tsx new file mode 100644 index 0000000..27b08b0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ErrorBoundary } from "./ErrorBoundary"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render({null}, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.tsx b/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..cfa0b0e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,224 @@ +import React, { + Component, + createContext, + ErrorInfo, + FC, + useContext, + useMemo, + useState, +} from "react"; +import * as Sentry from "@sentry/browser"; +import { customAlphabet } from "nanoid"; + +import { BasicDiscordWidget } from "../DiscordWidget/DiscordWidget"; +import { BigModal } from "../Modal"; +import { ErrorDetails } from "../ErrorDetails"; +import { FancyButton } from "../Fancy"; +import { IS_LOCAL } from "../../util/api"; +import { sentryConsoleLogAbortController } from "../../util/initSentry"; + +import "./ErrorBoundary.css"; + +/** + * We use this to generate a "quotable" HASH id – this is not guaranteed to be + * unique, but it doesn't really need to be, and there is a trade off between + * uniqueness and quotability. The result from nanoid is prepended with 2 digits + * representing the day of the month, which helps reduce likelihood of clashes. + * + * Using nanoid's "clash" tool, it would take 6 days generating 50 ids per hour + * to have a 1% chance of a clash – this will be reduced massively as these ids + * are scoped to the day of the month. + * + * @see https://zelark.github.io/nano-id-cc/ + */ +const quotableId = (() => { + const generateHashEventId = customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", + 6 + ); + + return () => + `CORE-${new Date() + .getUTCDate() + .toString() + .padStart(2, "0")}${generateHashEventId()}`; +})(); + +type ErrorBoundaryProps = {}; +type ErrorBoundaryState = { + didError: boolean; + errorName?: string; + errorMessage?: string; + errorStack?: string; + eventId: string | null; + detailsHidden: boolean; + hashEventId: string | null; +}; + +type TErrorBoundaryContext = { + handlePromiseRejection: (promise: Promise) => void; + fatalError: (err: any) => void; +} | null; + +const ErrorBoundaryContext = createContext(null); + +export const useHandlePromiseRejection = () => + useContext(ErrorBoundaryContext)!.handlePromiseRejection; + +export const useFatalError = () => useContext(ErrorBoundaryContext)!.fatalError; + +/** + * This component provides a `handlePromiseRejection` function to the + * application using a context provider. This function attaches a promise + * rejection handler to the passed promise. This allows async errors to be + * handled by the ErrorBoundary, even though they would not normally be caught + * by React. It uses the setState function from a useState hook to inform React + * about the error, which is a trick suggested by a member of the React team. + * The context value is memoized to ensure only one function is ever provided + * and that there are not unnecessary re-renders of children that depend on this + * function. + * + * @see https://github.com/facebook/react/issues/14981#issuecomment-468460187 + */ +const ErrorBoundaryContextProvider: FC = ({ children }) => { + const [, catchError] = useState(); + const contextValue = useMemo(() => { + const fatalError = (err: any) => { + catchError(() => { + throw err; + }); + }; + + return { + fatalError, + handlePromiseRejection(promise) { + promise.catch(fatalError); + }, + }; + }, []); + + return ( + + {children} + + ); +}; + +/** + * @warning ErrorBoundary is rendered above our Redux stores so it can use state + * inside Redux + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + static getDerivedStateFromError(error: Error) { + return { + didError: true, + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + hashEventId: IS_LOCAL ? null : quotableId(), + }; + } + + state = { + didError: false, + errorName: undefined, + errorMessage: undefined, + errorStack: undefined, + eventId: null, + detailsHidden: true, + hashEventId: null, + }; + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + const { hashEventId } = this.state; + + /** + * Cancel the in-flight sentry log of this error caused by React calling + * console.error before notifying the error boundary + */ + sentryConsoleLogAbortController.abort(); + + Sentry.withScope((scope) => { + scope.setTag("hashId", hashEventId); + scope.setExtras(errorInfo as any); + const eventId = Sentry.captureException(error); + this.setState({ eventId }); + }); + } + + render() { + const { + didError, + errorName, + errorMessage, + errorStack, + detailsHidden, + hashEventId, + } = this.state; + + return didError ? ( + +
    +
    +

    An error has occurred

    + {hashEventId ? ( +
    + {hashEventId} +
    + ) : null} +
    +

    + We're sorry for any inconvenience caused. We've logged your error + and will work to ensure it doesn't happen again. Any changes made to + your simulation should have been saved. +

    +

    + Please refresh this page and reattempt the operation, or see our{" "} + + troubleshooting guide + + . +

    +
    + {hashEventId ? ( +

    + If your error repeatedly reoccurs, please contact us quoting the + error ID in the top right corner of your screen. +

    + ) : null} +
    +
    + ) : ( + + {this.props.children} + + ); + } +} diff --git a/apps/sim-core/packages/core/src/components/ErrorBoundary/index.ts b/apps/sim-core/packages/core/src/components/ErrorBoundary/index.ts new file mode 100644 index 0000000..7e0b93c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ErrorBoundary/index.ts @@ -0,0 +1 @@ +export { ErrorBoundary, useHandlePromiseRejection } from "./ErrorBoundary"; diff --git a/apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.css b/apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.css new file mode 100644 index 0000000..ba7b5d7 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.css @@ -0,0 +1,4 @@ +.ErrorDetails--contents { + overflow: scroll; + max-height: 40vh; +} diff --git a/apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.spec.tsx b/apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.spec.tsx new file mode 100644 index 0000000..364896b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/ErrorDetails/ErrorDetails.spec.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ErrorDetails } from "./ErrorDetails"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • + + ), + [contextMenuStyle, dispatch, currentOpenFileInEditor] + ); + + useOnClickOutside(tabsRef, hideContextMenu); + + const embedded = useSelector(selectEmbedded); + + const canSave = useScope(Scope.save); + const canShowBehaviorKeys = + currentFile?.kind === HcFileKind.Behavior || + currentFile?.kind === HcFileKind.SharedBehavior; + + return ( +
    + + +
    + ); +}; + +// HashCoreEditor.whyDidYouRender = { +// // this is needed because the compenent is wrapped in `memo` so it's +// // `displayName` is `undefined` ... apparently `@welldone-software/why-did- +// // you-render`'s types are somewhat incomplete +// // +// // @ts-ignore +// customName: "HashCoreEditor" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorBehaviorKeysFileAction.tsx b/apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorBehaviorKeysFileAction.tsx new file mode 100644 index 0000000..dccd8b1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorBehaviorKeysFileAction.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { AppDispatch } from "../../../features/types"; +import { HcFileKind } from "../../../features/files/enums"; +import { IconBrain } from "../../Icon"; +import { SimpleTooltip } from "../../SimpleTooltip"; +import { fileActionSize } from "./utils"; +import { selectCurrentFile } from "../../../features/files/selectors"; +import { toggleBehaviorKeysEditor } from "../../../features/files/slice"; + +export const HashCoreEditorBehaviorKeysFileAction = () => { + const currentFile = useSelector(selectCurrentFile); + const dispatch = useDispatch(); + + if ( + currentFile?.kind !== HcFileKind.Behavior && + currentFile?.kind !== HcFileKind.SharedBehavior + ) { + return null; + } + + return ( + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorFile.tsx b/apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorFile.tsx new file mode 100644 index 0000000..c270e53 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Editor/HashCoreEditorFile.tsx @@ -0,0 +1,121 @@ +import React, { + Dispatch, + FC, + MutableRefObject, + SetStateAction, + Suspense, +} from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { AppDispatch } from "../../../features/types"; +import { BehaviorKeys } from "../../BehaviorKeys/BehaviorKeys"; +import { DataLoader } from "../../DataLoader/DataLoader"; +import { FileBannerWrapper } from "../../FileBanner"; +import { GlobalsEditor } from "../../GlobalsEditor"; +import { HcFile } from "../../../features/files/types"; +import { HcFileKind } from "../../../features/files/enums"; +import { + Scope, + selectVisualGlobalsVisible, + useScopes, +} from "../../../features/scopes"; +import { + TabbedEditorDiffPanel, + TabbedEditorPanel, + useMonacoContainerFromContext, +} from "../../TabbedEditor"; +import { ViewStates } from "../../TabbedEditor/Panel/TabbedEditorPanel"; +import { + canAutosuggestKeysForFile, + globalsFileId, +} from "../../../features/files/utils"; +import { getTextModelRequired } from "../../../features/monaco"; +import { selectCurrentProjectUrl } from "../../../features/project/selectors"; +import { selectShouldShowBehaviorKeys } from "../../../features/files/selectors"; +import { updateBehaviorKeysFile } from "../../../features/files/slice"; + +export const HashCoreEditorFile: FC<{ + file: HcFile; + onDidFallbackChange: Dispatch>; + tabsHeight?: number; + viewStatesRef: MutableRefObject; + nextContents: string | null; + onNextContentsChange: (nextContents: string | null) => unknown; +}> = ({ + file, + onDidFallbackChange, + tabsHeight, + viewStatesRef, + nextContents, + onNextContentsChange, +}) => { + const [editorInstance] = useMonacoContainerFromContext(); + const [diffEditorInstance] = useMonacoContainerFromContext(true); + + const dispatch = useDispatch(); + const projectUrl = useSelector(selectCurrentProjectUrl); + const shouldShowBehaviorKeys = useSelector(selectShouldShowBehaviorKeys); + const shouldShowGlobalEditor = useSelector(selectVisualGlobalsVisible); + const { canModifyFile, canSaveFile } = useScopes( + Scope.modifyFile, + Scope.saveFile + ); + + return file.kind === HcFileKind.Dataset ? ( + + ) : ( + <> + {shouldShowBehaviorKeys && + (file.kind === HcFileKind.Behavior || + file.kind === HcFileKind.SharedBehavior) ? ( + { + dispatch( + updateBehaviorKeysFile({ + fileId: file.id, + keys, + }) + ); + }} + /> + ) : file.id === globalsFileId && shouldShowGlobalEditor ? ( + + + + ) : null} + { + + } + {nextContents !== null ? ( + + ) : ( + + )} + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Editor/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Editor/index.ts new file mode 100644 index 0000000..4f3d46e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Editor/index.ts @@ -0,0 +1 @@ +export { HashCoreEditor } from "./HashCoreEditor"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Editor/utils.ts b/apps/sim-core/packages/core/src/components/HashCore/Editor/utils.ts new file mode 100644 index 0000000..3d112d7 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Editor/utils.ts @@ -0,0 +1,77 @@ +import { AnalysisJson } from "../../../features/analysis/analysisJsonTypes"; +import { AppDispatch } from "../../../features/types"; +import { HcFile } from "../../../features/files/types"; +import { HcFileKind } from "../../../features/files/enums"; +import { addUserAlert, clearUserAlerts } from "../../../features/viewer"; +import { globalsFileId } from "../../../features/files/utils"; +import { validateAnalysisJson } from "./../../../features/analysis/analysisJsonValidation"; + +export const fileActionSize = 18; + +/** + * @todo this should be a selector + */ +export const getDocsSection = ( + file?: HcFile, + behaviorKeysOpen?: boolean +): string => { + if (behaviorKeysOpen) { + return "behaviors/behavior-keys"; + } + + if (file?.kind === HcFileKind.Dataset) { + return "datasets"; + } + + switch (file?.id) { + case "analysis": + return "views/analysis"; + case globalsFileId: + return "configuration"; + case "experiments": + return "experiments"; + case "initialState": + return "anatomy-of-an-agent/state"; + + default: + return ""; + } +}; + +export const validateAnalysisJsonAndDispatchErrorsIfAny = ( + analysis: AnalysisJson, + dispatch: AppDispatch +) => { + const result = validateAnalysisJson(analysis); + dispatch(clearUserAlerts()); + + if ( + result.success && + result.warnings.length === 0 && + result.errors.length === 0 + ) { + // TODO: Add a success notification. This has the prerequisite of renaming the `completed` notification type + // to `success` and ensuring it does not break the simulation worker. + return true; + } + const alerts: any[] = []; + result.warnings.forEach((warning) => + alerts.push({ + type: "warning", + message: `${warning.message} (Code: ${warning.name})`, + }) + ); + result.errors.forEach((error) => + alerts.push({ + type: "error", + message: `${error.message} (Error code: ${error.name})`, + }) + ); + const baseAttrs = { + context: "analysis.json", + simulationId: "", + timestamp: Date.now(), + hideLinksToDocs: true, + }; + alerts.forEach((alert) => dispatch(addUserAlert({ ...alert, ...baseAttrs }))); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.css b/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.css new file mode 100644 index 0000000..b68a9ac --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.css @@ -0,0 +1,7 @@ +.HashCoreEditorContainer { + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.tsx b/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.tsx new file mode 100644 index 0000000..14b5e45 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/HashCoreEditorContainer.tsx @@ -0,0 +1,13 @@ +import React, { FC } from "react"; + +import { HashCoreConsole } from "../Console"; +import { HashCoreEditor } from "../Editor/HashCoreEditor"; + +import "./HashCoreEditorContainer.css"; + +export const HashCoreEditorContainer: FC = () => ( +
    + + +
    +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/index.ts b/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/index.ts new file mode 100644 index 0000000..334f736 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/EditorContainer/index.ts @@ -0,0 +1 @@ +export { HashCoreEditorContainer } from "./HashCoreEditorContainer"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.scss new file mode 100644 index 0000000..d6b7e4e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.scss @@ -0,0 +1,42 @@ +.HashCoreFiles { + background-color: var(--theme-dark); + + width: 100%; + flex: 1; + max-height: 100%; + + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.HashCoreFiles__Actions { + display: flex; + list-style: none; + justify-content: flex-start; + align-items: stretch; + padding-left: calc(1rem - 4px); + height: 34px; + background-color: var(--theme-darkest); + margin: 0; + flex-shrink: 0; +} + +.HashCoreFiles ul:not(.HashCoreFiles__Actions) { + list-style-type: none; + padding: 0; + overflow-y: auto; + box-sizing: border-box; + flex: 1 1 auto; + margin: 0; +} + +.HashCoreFiles__Files { + padding-top: 0.4rem !important; + + /* for applying consistent spacing to icons in the File Viewer */ + *:not(.FileNameWithIcon) > .Icon { + fill: var(--theme-white); + padding-right: 4px; + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.tsx new file mode 100644 index 0000000..2243ea2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFiles.tsx @@ -0,0 +1,234 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useModal } from "react-modal-hook"; +import { PayloadAction } from "@reduxjs/toolkit"; +import { filter } from "rxjs/operators"; + +import { ExperimentModal } from "../../Modal/Experiments/ExperimentModal"; +import { HashCoreFilesHeaderAction } from "./HashCoreFilesHeaderAction"; +import { + HashCoreFilesListItemFilePending, + getDomIdByFileId, +} from "./ListItemFile"; +import { HashCoreFilesListItemFolder, useNameNewBehaviorModal } from "."; +import { HcFile } from "../../../features/files/types"; +import { HcFileKind } from "../../../features/files/enums"; +import { IconExperimentsCreate, IconFilePlus, IconMagnify } from "../../Icon"; +import { ModalNewDataset } from "../../Modal/NewDataset/ModalNewDataset"; +import { Scope, useScopes } from "../../../features/scopes"; +import { addPreparedFile } from "../../../features/files/slice"; +import { openSearch } from "../../../features/search"; +import { + selectCurrentFileRepoPath, + selectFolderTree, + selectPendingDependencies, +} from "../../../features/files/selectors"; +import { storeActionObservable } from "../../../features/actionObservable"; +import { useResizeObserver } from "../../../hooks/useResizeObserver/useResizeObserver"; + +import "./HashCoreFiles.scss"; + +const calculateOpenFoldersForPath = ( + currentRepoPath: string, + existingOpenFolders: Record = {} +) => + currentRepoPath + .split("/") + .reduce>((newOpenPaths, _, idx, parts) => { + const path = parts.slice(0, idx).join("/"); + + if (path && !existingOpenFolders[path]) { + newOpenPaths[path] = true; + } + + return newOpenPaths; + }, {}); + +export const HashCoreFiles: FC = () => { + const pendingFiles = useSelector(selectPendingDependencies); + const { canSave, canEdit } = useScopes( + Scope.save, + Scope.uploadDataset, + Scope.edit + ); + const currentRepoPath = useSelector(selectCurrentFileRepoPath); + const dispatch = useDispatch(); + + const showNameBehavior = useNameNewBehaviorModal(); + const [_showNewDatasetModal, hideNewDatasetModal] = useModal( + () => , + [] + ); + + // This is set by whichever child component is current + const scrollIntoViewRef = useRef(null); + const paneRef = useRef(null); + const observerRef = useResizeObserver(() => { + scrollIntoViewRef.current?.(); + }); + const setPaneRef = useCallback( + (node: HTMLDivElement | null) => { + paneRef.current = node; + observerRef(node); + }, + [observerRef] + ); + + const tree = useSelector(selectFolderTree); + + const [openPaths, setOpenPaths] = useState>(() => + currentRepoPath ? calculateOpenFoldersForPath(currentRepoPath) : {} + ); + + /** + * This could be a ref but then modifying it outside of an effect would break + * React concurrent mode – instead we use state because we can queue an update + * to it without breaking CM. + */ + const [lastRepoPath, setLastRepoPath] = useState( + currentRepoPath + ); + + const toggleOpen = useCallback((path: string) => { + setOpenPaths((openPaths) => ({ + ...openPaths, + [path]: !openPaths[path], + })); + }, []); + + /** + * This ensures the data folder is open when datasets are uploaded. + * We purposefully don't open the file because we don't want to download it + * if it's not necessary to – but we do want to indicate to the user that + * something changed. + * + * @todo move folder state to Redux so can do this in Redux + */ + useEffect(() => { + const subscription = storeActionObservable + .pipe( + filter((action): action is PayloadAction => + addPreparedFile.match(action) + ) + ) + .subscribe((action) => { + const file = action.payload; + + if (file.kind === HcFileKind.Dataset) { + setOpenPaths((openPaths) => ({ + ...openPaths, + ...calculateOpenFoldersForPath(file.repoPath, openPaths), + })); + + /** + * @todo don't rely on querying for ids for this + */ + setImmediate(() => { + document + .querySelector(`#${getDomIdByFileId(file.id)}`) + ?.scrollIntoView({ block: "center", inline: "center" }); + }); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + const [ + openCreateExperimentModal, + hideCreateExperimentModal, + ] = useModal(() => ); + + // Ensure the current file is visible when we change tabs + if (currentRepoPath && currentRepoPath !== lastRepoPath) { + const newOpenPaths = calculateOpenFoldersForPath( + currentRepoPath, + openPaths + ); + + if (Object.keys(newOpenPaths).length) { + setOpenPaths({ + ...openPaths, + ...newOpenPaths, + }); + } + + setLastRepoPath(currentRepoPath); + } + + return ( +
    +
      + {canSave ? ( + { + evt.preventDefault(); + showNameBehavior(); + }} + > + + + ) : null} + {/* {canUploadDataset ? ( + { + evt.preventDefault(); + showNewDatasetModal(); + }} + > + + + ) : null} */} + {canSave ? ( + { + evt.preventDefault(); + openCreateExperimentModal(); + }} + > + + + ) : null} + { + evt.preventDefault(); + dispatch(openSearch()); + }} + > + + +
    + +
      + + {pendingFiles.map((id) => ( + + ))} +
    +
    + ); +}; + +// // @ts-ignore +// HashCoreFiles.whyDidYouRender = { +// customName: "HashCoreFiles" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.scss new file mode 100644 index 0000000..2163371 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.scss @@ -0,0 +1,88 @@ +.HashCoreFilesHeaderActionTooltip { + --clip-y-below: 0; + top: 0; + font-size: 10px; + text-align: left; + min-width: 0; + padding: 8px 13px; +} + +.HashCoreFilesHeaderAction { + appearance: none; + display: flex; + align-items: center; + height: 100%; + box-sizing: border-box; + width: 28px; + padding: 0; + justify-content: center; + border: 0; + background-color: var(--theme-darkest); + + /** + * This ensures the icons share a border on hover – just reduces the space + * between them a little which is more visually pleasant + */ + margin-right: -1px; + position: relative; + @media (any-hover: hover) { + &:hover { + z-index: 1; + } + } + + .Icon { + fill: var(--theme-grey); + opacity: 0.7; + height: 11px; + transform-origin: center; + } + + /** + * These numbers are the ratio between the rendered icon size and the size of + * the viewbox for each icon. Using a transform for this means I can set the + * icon size universally using .Icon – the overflow is to prevent the svg's + * viewbox from breaking out of the button + */ + overflow: hidden; + .IconFilePlus, + .IconExperimentsCreate { + transform: scale(1.6); + } + + .IconTableAdd { + transform: translateY(1px) scale(2); + } + + .IconMagnify { + transform: scale(1.829); + } + + @media (any-hover: hover) { + &:hover { + /** + * Using box shadow ensures the borders don't take up space and act as + * overlays – it also ensures 90deg edges to the borders, rather than 45deg + * which is what you get when using CSS borders + */ + box-shadow: inset 1px 0 0 var(--theme-border), + inset -1px 0 0 var(--theme-border); + + .Icon { + opacity: 1; + } + } + } +} + +.HashCoreFilesHeaderAction--left { + .SimpleTooltip-PositionHelper { + left: 1px; + } +} + +.HashCoreFilesHeaderAction--right { + .SimpleTooltip-PositionHelper { + right: 1px; + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.tsx new file mode 100644 index 0000000..279f00c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/HashCoreFilesHeaderAction.tsx @@ -0,0 +1,80 @@ +import React, { + ButtonHTMLAttributes, + FC, + RefObject, + useRef, + useState, +} from "react"; +import classNames from "classnames"; + +import { SimpleTooltip } from "../../SimpleTooltip"; + +import "./HashCoreFilesHeaderAction.scss"; + +export const HashCoreFilesHeaderAction: FC< + ButtonHTMLAttributes & { + paneRef?: RefObject; + } +> = ({ title, children, className, paneRef, ...props }) => { + const buttonRef = useRef(null); + const tooltipRef = useRef(null); + const [position, setPosition] = useState<"left" | "right">("left"); + + const onTooltipOpenChange = (open: boolean) => { + if (open) { + const button = buttonRef.current; + const tooltip = tooltipRef.current; + const pane = paneRef?.current; + + if (!button || !tooltip || !pane) { + return; + } + + const buttonRect = button.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const max = pane.getBoundingClientRect().width; + const farRight = buttonRect.left + tooltipRect.width; + + if (position === "left") { + if (farRight >= max) { + setPosition("right"); + } + } else { + const farLeft = buttonRect.left + buttonRect.width - tooltipRect.width; + + if (farRight < max || farLeft <= 0) { + setPosition("left"); + } + } + } + }; + + return ( +
  • + +
  • + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.scss new file mode 100644 index 0000000..658c213 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.scss @@ -0,0 +1,27 @@ +.HashCoreFilesListItem { + --background-color: var(--theme-dark); + --background-color-transparent: var(--theme-dark-transparent); + background-color: var(--background-color); + align-items: center; + box-sizing: border-box; + display: flex; + flex-direction: row; + min-height: var(--files-list-item-height); + padding-left: 0.7rem; + padding-right: 1rem; + position: relative; + width: 100%; + cursor: pointer; + + > .Icon:first-child { + margin-left: -2px; + } + + @media (any-hover: hover) { + &:hover { + --background-color: var(--theme-dark-hover); + --background-color-transparent: var(--theme-dark-hover-transparent); + color: var(--theme-white); + } + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.tsx new file mode 100644 index 0000000..38b4f06 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItem/HashCoreFilesListItem.tsx @@ -0,0 +1,16 @@ +import React, { FC, HTMLProps } from "react"; +import classNames from "classnames"; + +import "./HashCoreFilesListItem.scss"; + +export const HashCoreFilesListItem: FC< + HTMLProps & { depth: number } +> = ({ className, children, depth, style = {}, ...props }) => ( +
    + {children} +
    +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.scss new file mode 100644 index 0000000..6eda687 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.scss @@ -0,0 +1,84 @@ +.HashCoreFilesListItemFile { + /* make the background fill the container */ + transition: background-color 0.1s; + padding-left: 0; + user-select: none; + display: flex; + box-sizing: border-box; + flex-direction: row; + height: 1.64rem; + align-items: center; + justify-content: space-between; + position: relative; + + margin-right: 0; + cursor: pointer; +} + +.HashCoreFilesListItemFile__Pending { + justify-content: center; + cursor: default; +} + +.HashCoreFilesListItemFile__Delete { + align-items: center; + justify-content: center; + border: none; + margin: 0; + cursor: pointer; + user-select: none; + + color: var(--theme-grey); + fill: var(--theme-grey); + + position: absolute; + right: 1rem; + top: 0; + bottom: 0; + padding-left: 0; + padding-right: 0; + background-color: var(--background-color); + + display: none; + @media (any-hover: hover) { + display: flex; + + .HashCoreFilesListItemFile:not(:hover) & { + display: none; + } + + &:hover { + color: var(--theme-white); + fill: var(--theme-white); + } + } +} + +.HashCoreFilesListItemFile__Delete__Fade { + position: absolute; + top: 0; + right: 100%; + width: 40px; + height: 100%; + pointer-events: none; + background: linear-gradient( + to right, + var(--background-color-transparent), + var(--background-color) + ); +} + +.HashCoreFilesListItemFile__SharedBehaviorIndicator { + display: inline-flex; + align-self: center; + align-items: center; + padding: 0; + color: var(--theme-white); + fill: var(--theme-white); + margin: 0; +} + +.HashCoreFilesListItemFile .Icon.IconTrash { + min-width: auto; + min-height: auto; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.spec.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.spec.tsx new file mode 100644 index 0000000..19acf8e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.spec.tsx @@ -0,0 +1,41 @@ +// TODO: Fix these tests. They are suffering from the same problem as ExperimentModal.spec.tsx +// And it is also happening here because calls the ExperimentModal, which in +// turn needs to be wrapped with the proper providers + +describe.skip("HashCoreFilesListItemFile tests", () => { + it.todo("Please FIXME. See this file for more info."); +}); + +export const thisMustBeHereToMakeTheBuildHappyAboutTheFactThatWeDoNotHaveAnImport = + "this must Be Here To Make The Build Happy About The Fact That We Do Not Have An Import"; + +// import React from "react"; +// import ReactDOM from "react-dom"; +// import { Provider } from "react-redux"; +// import { ModalProvider } from "react-modal-hook"; + +// import { HashCoreFilesListItemFile } from "./HashCoreFilesListItemFile"; +// import { mockProject } from "../../../../features/project/mocks"; +// import { setProjectWithMeta } from "../../../../features/actions"; +// import { store } from "../../../../features/store"; + +// Element.prototype.scrollIntoView = jest.fn(); + +// it("renders without crashing", () => { +// const div = document.createElement("div"); + +// store.dispatch(setProjectWithMeta(mockProject)); + +// ReactDOM.render( +// +// +// +// +// , +// div +// ); +// ReactDOM.unmountComponentAtNode(div); +// }); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.tsx new file mode 100644 index 0000000..7402084 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFile.tsx @@ -0,0 +1,320 @@ +import React, { + CSSProperties, + FC, + MutableRefObject, + useEffect, + useRef, + useState, +} from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useModal } from "react-modal-hook"; +import urljoin from "url-join"; + +import { AppDispatch } from "../../../../features/types"; +import { Ext } from "../../../../util/files/enums"; +import { FileNameWithShortnameIcon } from "../../../FileName/FileNameWithShortnameIcon"; +import { HashCoreContextMenu } from "../../ContextMenu"; +import { HashCoreFilesListItem } from "../ListItem/HashCoreFilesListItem"; +import { HcFileKind } from "../../../../features/files/enums"; +import { IconAccountMultiple, IconTrash } from "../../../Icon"; +import { LinkBehavior } from "../../../Link/LinkBehavior"; +import { ModalConfirmFileDelete, ModalReleaseBehavior } from "../../../Modal"; +import { ReleaseMeta } from "../../../../util/api/types"; +import { SITE_URL } from "../../../../util/api/paths"; +import { Scope, useScope } from "../../../../features/scopes"; +import { + deleteFile, + renameInitFile, + setCurrentFileId, + updateFile, +} from "../../../../features/files/slice"; +import { getReleaseMeta } from "../../../../util/api"; +import { isSharedDependency } from "../../../../features/files/utils"; +import { + selectCurrentProject, + selectProjectPublishedFiles, +} from "../../../../features/project/selectors"; +import { useClipboardWriteText } from "../../../../hooks/useClipboardWriteText"; +import { + useFileIsCurrent, + useSelectFileById, +} from "../../../../features/files/hooks"; +import { useOnClickOutside } from "../../../../hooks/useOnClickOutside"; +import { useRenameBehaviorModal } from ".."; + +import "./HashCoreFilesListItemFile.scss"; + +type HashCoreFilesListItemFileProps = { + fileId: string; + scrollIntoViewRef?: MutableRefObject; + depth?: number; +}; + +export const getDomIdByFileId = (id: string) => `HashCoreFilesListItem-${id}`; + +export const HashCoreFilesListItemFile: FC = ({ + fileId, + scrollIntoViewRef, + depth = 1, +}) => { + const file = useSelectFileById(fileId); + const dispatch = useDispatch(); + const publishedFiles = useSelector(selectProjectPublishedFiles); + const canSave = useScope(Scope.save); + const project = useSelector(selectCurrentProject); + const current = useFileIsCurrent(fileId); + const clipboardWriteText = useClipboardWriteText(); + + const fileIsBehavior = file.kind === HcFileKind.Behavior; + const filePublished = + fileIsBehavior && publishedFiles.includes(file.path.formatted); + const canRename = canSave && fileIsBehavior && !filePublished; + const canPublish = canRename && project?.visibility === "public"; + const canDelete = + canSave && + file.kind !== HcFileKind.Required && + file.kind !== HcFileKind.Init && + !filePublished; + + const title = file.path?.formatted ?? ""; + const [showConfirmDelete, hideConfirmDelete] = useModal( + () => ( + { + if (confirm) { + dispatch(deleteFile(file.id)); + } else { + hideConfirmDelete(); + } + }} + /> + ), + [title, dispatch, file.id] + ); + + const [data, setData] = useState(null); + const [showReleaseBehaviorModal, hideReleaseBehaviorModal] = useModal( + () => + data && file.kind === HcFileKind.Behavior ? ( + + ) : null, + [data, file] + ); + + const showNameBehavior = useRenameBehaviorModal(file.id, file.path); + + const [contextMenuStyle, setContextMenuStyle] = useState< + Pick + >({ + top: 0, + left: 0, + }); + const [showContextMenu, hideContextMenu] = useModal( + () => ( + + {canSave && canPublish && ( +
  • + +
  • + )} + {isSharedDependency(file) ? ( + <> +
  • + + View in HASH + +
  • + {file.kind === HcFileKind.SharedBehavior && + file.path.ext !== Ext.Rs ? ( +
  • + + {file.canUserEdit ? <>Edit : <>View} behavior in + original context + +
  • + ) : null} + + ) : null} +
  • + +
  • + {canRename && ( +
  • + +
  • + )} + {canDelete && ( +
  • + +
  • + )} + {file.kind === HcFileKind.Init && file.path.ext !== Ext.Js && ( +
  • + +
  • + )} + {file.kind === HcFileKind.Init && file.path.ext !== Ext.Py && ( +
  • + +
  • + )} + {file.kind === HcFileKind.Init && file.path.ext !== Ext.Json && ( +
  • + +
  • + )} +
    + ), + [ + clipboardWriteText, + contextMenuStyle, + canDelete, + file, + canPublish, + canSave, + canRename, + showConfirmDelete, + showNameBehavior, + showReleaseBehaviorModal, + dispatch, + ] + ); + + const listItemRef = useRef(null); + + useOnClickOutside(listItemRef, hideContextMenu); + + useEffect(() => { + if (current) { + const resize = () => { + listItemRef.current?.scrollIntoView({ block: "nearest" }); + }; + + resize(); + + if (scrollIntoViewRef) { + scrollIntoViewRef.current = resize; + } + } + }, [current, scrollIntoViewRef]); + + return ( +
  • { + evt.stopPropagation(); // needed to avoid collapsing the parent folder + evt.preventDefault(); + dispatch(setCurrentFileId(fileId)); + }} + onContextMenu={(evt) => { + evt.preventDefault(); + + setContextMenuStyle({ + top: evt.pageY - 4, + left: evt.pageX + 4, + }); + + showContextMenu(); + }} + ref={listItemRef} + > + + + {filePublished ? ( +
    + +
    + ) : null} + {canDelete && ( +
    { + event.stopPropagation(); + showConfirmDelete(); + }} + > +
    + +
    + )} + +
  • + ); +}; + +// HashCoreFilesListItem.whyDidYouRender = { +// // @ts-ignore +// customName: "HashCoreFilesListItem" +// }; + +const initJSHeader = + "/**\n" + " * @param {InitContext} context for initialization\n" + " */"; + +// Converts the contents of a JSON init file to a JavaScript init file. It creates the +// init function and places the JSON contents into the function's body. +const initJsonToJs = (contents: string) => { + const body = contents.replaceAll("\n", "\n ").trim(); + return `${initJSHeader}\nconst init = (context) => {\n let agents = ${body};\n return agents;\n}\n`; +}; + +// Converts the contents of a JSON init file to a Python init file. It creates the +// init function and places the JSON contents into the function's body. +const initJsonToPy = (contents: string) => { + const body = contents.replaceAll("\n", "\n ").trim(); + return `def init(context):\n agents = ${body}\n return agents\n`; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFilePending.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFilePending.tsx new file mode 100644 index 0000000..f8f3d53 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/HashCoreFilesListItemFilePending.tsx @@ -0,0 +1,13 @@ +import React, { FC } from "react"; + +import { FileName } from "../../../FileName/FileName"; + +import "./HashCoreFilesListItemFile.scss"; + +export const HashCoreFilesListItemFilePending: FC = () => ( +
  • + + Loading… + +
  • +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/index.ts new file mode 100644 index 0000000..0311625 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFile/index.ts @@ -0,0 +1,6 @@ +export { + HashCoreFilesListItemFile, + getDomIdByFileId, +} from "./HashCoreFilesListItemFile"; + +export { HashCoreFilesListItemFilePending } from "./HashCoreFilesListItemFilePending"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.css b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.css new file mode 100644 index 0000000..752d1e5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.css @@ -0,0 +1,30 @@ +:root { + --files-list-item-height: 1.64rem; +} + +/** @todo remove unnecessary styles */ +.HashCoreFilesListItemFolder { + user-select: none; + display: flex; + box-sizing: border-box; + flex-direction: row; + flex-wrap: wrap; + min-height: var(--files-list-item-height); + align-items: center; + align-self: center; + justify-content: space-between; + position: relative; + margin-right: 0; + width: 100%; +} + +.HashCoreFilesListItemFolder__Items { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; +} + +.HashCoreFilesListItemFolder__Items--closed { + display: none; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.spec.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.spec.tsx new file mode 100644 index 0000000..ac197b1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.spec.tsx @@ -0,0 +1,167 @@ +// TODO: Fix these tests. They are suffering from the same problem as ExperimentModal.spec.tsx +// And it is also happening here because calls the ExperimentModal, which in +// turn needs to be wrapped with the proper providers + +describe.skip("HashCoreFilesListItemFolder tests", () => { + it.todo("Please FIXME. See this file for more info."); +}); + +export const thisMustBeHereToMakeTheBuildHappyAboutTheFactThatWeDoNotHaveAnImport = + "this must Be Here To Make The Build Happy About The Fact That We Do Not Have An Import"; +// import React from "react"; +// import ReactDOM from "react-dom"; +// import { Provider } from "react-redux"; +// import { ModalProvider } from "react-modal-hook"; +// import { render, screen, fireEvent } from "@testing-library/react"; + +// import { HashCoreFilesListItemFolder } from "./HashCoreFilesListItemFolder"; +// import { mockProject } from "../../../../features/project/mocks"; +// import { parseRelativePathsAsTree } from "../../../../features/files/utils"; +// import { setProjectWithMeta } from "../../../../features/actions"; +// import { store } from "../../../../features/store"; + +// Element.prototype.scrollIntoView = jest.fn(); +// const mockFiles = parseRelativePathsAsTree(mockProject.files); + +// it("renders without crashing", () => { +// const div = document.createElement("div"); + +// store.dispatch(setProjectWithMeta(mockProject)); + +// ReactDOM.render( +// +// +// {}} +// openPaths={{}} +// /> +// +// , +// div +// ); +// ReactDOM.unmountComponentAtNode(div); +// }); + +// it("renders null if no files", () => { +// const div = document.createElement("div"); + +// store.dispatch(setProjectWithMeta(mockProject)); + +// const result = ReactDOM.render( +// +// +// {}} +// openPaths={{}} +// /> +// +// , +// div +// ); +// expect(result).toBe(null); +// ReactDOM.unmountComponentAtNode(div); +// }); + +// it("should render the root virtual folder", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const { getByTestId } = render( +// +// +// {}} +// openPaths={{}} +// /> +// +// +// ); +// const element = getByTestId("HashCoreFilesListItemFolder-"); +// expect(element).toBeDefined(); +// expect(element.textContent).not.toContain("root"); // must never display the span +// }); + +// it("should call toggleOpen when clicked", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); + +// const target = screen.getByText("src"); +// fireEvent.click(target); +// expect(mockFn).toHaveBeenCalled(); +// }); + +// it("should show folder closed when isOpen=false", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// render( +// +// +// {}} +// openPaths={{}} +// /> +// +// +// ); + +// const target: any = screen.getByText("src"); +// const sibling = Object.values(target.previousElementSibling); +// const icon: any = sibling.pop(); +// expect(icon.className).toBe("Icon IconFolder"); +// }); + +// it("should show folder open when openPaths contains the selected path", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// render( +// +// +// {}} +// openPaths={{ src: true }} +// /> +// +// +// ); + +// let target: any = screen.getByText("src"); +// fireEvent.click(target); +// target = screen.getByText("src"); +// const sibling = Object.values(target.previousElementSibling); +// const icon: any = sibling.pop(); +// expect(icon.className).toBe("Icon IconFolderOpen"); +// }); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.tsx new file mode 100644 index 0000000..d3fd31d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/HashCoreFilesListItemFolder.tsx @@ -0,0 +1,94 @@ +import React, { FC, MutableRefObject } from "react"; +import classNames from "classnames"; + +import { FileNameWithIcon } from "../../../FileName/FileNameWithIcon"; +import { HashCoreFilesListItem } from "../ListItem/HashCoreFilesListItem"; +import { HashCoreFilesListItemFile } from "../"; +import { HcFile, HcFolder } from "../../../../features/files/types"; + +import "./HashCoreFilesListItemFolder.css"; + +type HashCoreFilesListItemFolderProps = { + scrollIntoViewRef?: MutableRefObject; + childrenItems?: HcFile[] | HcFolder[]; // the files contained in the folder + name: string; // the name of the current folder + repoPath: string; // the absolute path to the current folder + isOpen?: boolean; // whether the current folder is open + rootFolder?: boolean; // used only in HashCoreFiles to render a "virtual" root folder + toggleOpen: (path: string) => void; + openPaths: Record; +}; + +export const HashCoreFilesListItemFolder: FC = ({ + scrollIntoViewRef, + childrenItems = [], + name, + repoPath, + isOpen = false, + rootFolder = false, + toggleOpen, + openPaths, +}) => { + const folderOpen = isOpen || openPaths[repoPath]; + const folders = childrenItems.filter( + (item) => item.children && item.children.length > 0 + ); + const files = childrenItems.filter( + (item) => item.children && item.children.length === 0 + ); + + const id = `HashCoreFilesListItemFolder-${repoPath.replace(/\//g, "_")}`; + const depth = repoPath.split("/").length; + + return ( +
  • + {rootFolder ? null : ( + { + evt.stopPropagation(); // to prevent closing the parent folder + evt.preventDefault(); + + if (!rootFolder) { + toggleOpen(repoPath); + } + }} + > + + {name} + + + )} +
      + {folders.map((childrenItem) => ( + + ))} + {files.map((childrenItem) => ( + + ))} +
    +
  • + ); +}; + +// HashCoreFilesListItemFolder.whyDidYouRender = { +// // @ts-ignore +// customName: "HashCoreFilesListItemFolder" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/index.ts new file mode 100644 index 0000000..415f93a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/ListItemFolder/index.ts @@ -0,0 +1 @@ +export { HashCoreFilesListItemFolder } from "./HashCoreFilesListItemFolder"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.css b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.css new file mode 100644 index 0000000..f6c61f5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.css @@ -0,0 +1,19 @@ +.HashCoreFilesSearch { + --background-color: var(--theme-dark); + background-color: var(--background-color); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 5; + overflow: auto; + padding: 5px; + box-sizing: border-box; +} + +.HashCoreFilesSearch > ul { + padding: 0; + margin: 10px 0 0; + list-style-type: none; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.tsx new file mode 100644 index 0000000..b3af8f5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearch.tsx @@ -0,0 +1,83 @@ +import React, { FC, RefObject } from "react"; + +import { HashCoreFilesSearchFile } from "./HashCoreFilesSearchFile"; +import { HashCoreFilesSearchForm } from "./HashCoreFilesSearchForm"; +import { HashCoreFilesSearchProgress } from "./HashCoreFilesSearchProgress"; +import { KeepInViewProvider } from "../../../KeepInView"; +import { SearchDispatch, SearchState } from "./reducer"; +import { replace } from "./util"; +import { + useMonacoSearchHighlightDecorator, + useReplaceProposal, + useRevealMatchInEditor, + useSearch, +} from "./hooks"; + +import "./HashCoreFilesSearch.css"; + +export const HashCoreFilesSearch: FC<{ + searchState: SearchState; + searchDispatch: SearchDispatch; + searchInputRef: RefObject; + replaceInputRef: RefObject; +}> = ({ searchState, searchDispatch, searchInputRef, replaceInputRef }) => { + const { query, pending, results, noResults } = searchState; + const revealSelection = useRevealMatchInEditor(); + + useSearch(searchState, searchDispatch); + useMonacoSearchHighlightDecorator(results); + useReplaceProposal(query.replacing, results); + + return ( + + + + {noResults ?

    No results

    : null} +
      + {results.map(({ file, model, matches, replacing }) => ( +
    • + { + const range = match.range; + const replacing = query.replacing; + + revealSelection(replacing, file, model, matches, range); + }} + onFileClick={(file) => { + revealSelection(replacing, file, model, matches); + }} + onReplace={async (match) => { + await replace(model, file, [ + { + range: match.range, + replaceTerm: match.replaceTerm, + }, + ]); + }} + onReplaceAllInFile={async () => { + if ( + !window.confirm( + `Would you like to replace all instances of ${query.searchTerm} with ${query.replaceTerm} in this file?` + ) + ) { + return; + } + + await replace(model, file, matches); + }} + replacing={replacing} + pending={pending} + /> +
    • + ))} +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchContainer.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchContainer.tsx new file mode 100644 index 0000000..52a47a0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchContainer.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { HashCoreFilesSearch } from "./HashCoreFilesSearch"; +import { + closeSearch, + openSearch, + selectSearchOpen, +} from "../../../../features/search"; +import { focusAndSelect } from "./util"; +import { useKeyboardShortcuts } from "../../../../hooks/useKeyboardShortcuts"; +import { useSearchReducer } from "./reducer"; + +/** + * This is a container that stores the state for our search panel and also + * attaches keyboard events (so that keyboard shortcuts work even when the + * panel is not open). + */ +export const HashCoreFilesSearchContainer = () => { + const searchInputRef = useRef(null); + const replaceInputRef = useRef(null); + const searchOpen = useSelector(selectSearchOpen); + const appDispatch = useDispatch(); + const [searchState, searchDispatch] = useSearchReducer(); + + const ensureOpen = () => { + if (!searchOpen) { + appDispatch(openSearch()); + } + }; + + useEffect(() => { + if (searchOpen) { + searchInputRef.current?.focus(); + } + }, [searchOpen]); + + const openProjectWideSearch = () => { + ensureOpen(); + + if (searchState.query.replacing) { + searchDispatch({ type: "replacing", payload: false }); + } + + focusAndSelect(searchInputRef.current); + }; + useKeyboardShortcuts({ + meta: { + f: openProjectWideSearch, + }, + metaShift: { + f: openProjectWideSearch, + }, + single: { + Escape: () => { + if (searchOpen) { + appDispatch(closeSearch()); + } + }, + }, + }); + + return searchOpen ? ( + + ) : null; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.css b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.css new file mode 100644 index 0000000..168a818 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.css @@ -0,0 +1,15 @@ +.HashCoreFilesSearchFade { + pointer-events: none !important; + width: 15px; + left: -15px; + position: absolute; + top: 0; + bottom: 0; + background-image: linear-gradient( + to right, + transparent, + var(--background-color) + ); + opacity: 1; + transition: opacity 0.2s ease; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.tsx new file mode 100644 index 0000000..83fa65e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFade.tsx @@ -0,0 +1,7 @@ +import React, { FC } from "react"; + +import "./HashCoreFilesSearchFade.css"; + +export const HashCoreFilesSearchFade: FC = () => ( +
    +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.scss new file mode 100644 index 0000000..e58083c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.scss @@ -0,0 +1,30 @@ +.HashCoreFilesSearchFile { + line-height: 1.3em; + margin-bottom: 10px; +} + +.HashCoreFilesSearchFile > ul { + padding: 0; + margin: 0; + list-style-type: none; +} + +.HashCoreFilesSearchFile__Title { + border-radius: 3px; + user-select: none; + overflow: hidden; + white-space: nowrap; + cursor: pointer; + width: 100%; + box-sizing: border-box; + margin-bottom: 5px; + font-size: 0.8rem; + padding-left: 5px; + + @media (any-hover: hover) { + &:hover { + --background-color: var(--theme-light-on-dark); + background-color: var(--background-color); + } + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.tsx new file mode 100644 index 0000000..e6f9969 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchFile.tsx @@ -0,0 +1,77 @@ +import React, { FC } from "react"; + +import { FileNameWithShortname } from "../../../FileName/FileNameWithShortname"; +import { HashCoreFilesSearchItemWithIcons } from "./HashCoreFilesSearchItemWithIcons"; +import { HashCoreFilesSearchMatch } from "./HashCoreFilesSearchMatch"; +import type { HcFile } from "../../../../features/files/types"; +import { MonacoIconButton } from "./MonacoIconComponents"; +import type { SearchMatch } from "./types"; + +import "./HashCoreFilesSearchFile.scss"; + +type SearchFileProps = { + file: HcFile; + matches: SearchMatch[]; + onClick: (match: SearchMatch) => void; + onFileClick: (file: HcFile) => void; + onReplace: (match: SearchMatch) => Promise; + onReplaceAllInFile: () => Promise; + replacing: boolean; + pending: boolean; +}; + +export const HashCoreFilesSearchFile: FC = ({ + file, + matches, + onClick, + onFileClick, + onReplace, + onReplaceAllInFile, + replacing, + pending, +}) => ( +
    +
    { + evt.preventDefault(); + + onFileClick(file); + }} + > + { + await onReplaceAllInFile(); + }} + /> + ) : null + } + > + + +
    +
      + {matches.map((match) => ( +
    • + +
    • + ))} +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.css b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.css new file mode 100644 index 0000000..c7c6c72 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.css @@ -0,0 +1,30 @@ +.HashCoreFilesSearchForm { + display: flex; + padding: 10px 3px 10px 0; +} + +.HashCoreFilesSearchForm__Toggle { + display: flex !important; + align-items: center; +} + +.HashCoreFilesSearchForm__Inputs { + flex: 1; + margin-left: 5px; +} + +.HashCoreFilesSearchForm__Section { + display: flex; + align-items: stretch; +} + +.HashCoreFilesSearchForm__Section + .HashCoreFilesSearchForm__Section { + margin-top: 5px; +} + +.HashCoreFilesSearchForm__Section .button { + padding: 0 5px 0 4px; + margin-left: 4px; + display: flex; + align-items: center; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.tsx new file mode 100644 index 0000000..17b1f4f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchForm.tsx @@ -0,0 +1,157 @@ +import React, { FC, RefObject } from "react"; +import { useDispatch } from "react-redux"; + +import type { AppDispatch } from "../../../../features/types"; +import { HashCoreFilesSearchInput } from "./HashCoreFilesSearchInput"; +import { + MonacoIconButton, + MonacoIconCheckbox, + MonacoIconToggle, +} from "./MonacoIconComponents"; +import { Scope, useScope } from "../../../../features/scopes"; +import { SearchDispatch, SearchState } from "./reducer"; +import { closeSearch } from "../../../../features/search/slice"; +import { replace } from "./util"; +import { useCanHover } from "../../../../hooks/useCanHover"; + +import "./HashCoreFilesSearchForm.css"; + +export const HashCoreFilesSearchForm: FC<{ + searchState: SearchState; + searchDispatch: SearchDispatch; + searchInputRef: RefObject; + replaceInputRef: RefObject; +}> = ({ searchState, searchDispatch, searchInputRef, replaceInputRef }) => { + const { query, pending, results } = searchState; + const appDispatch = useDispatch(); + const canEdit = useScope(Scope.edit); + const canHover = useCanHover(); + + return ( +
    { + evt.preventDefault(); + }} + autoComplete="off" + > +
    + {canEdit && canHover ? ( + { + searchDispatch({ + type: "replacing", + payload: !open, + }); + }} + /> + ) : null} +
    +
    + + searchDispatch({ + type: "searchTerm", + payload: evt.target.value, + }) + } + placeholder="Search" + icons={ + <> + { + searchDispatch({ + type: "caseSensitive", + payload: !checked, + }); + searchInputRef.current?.focus(); + }} + /> + { + searchDispatch({ + type: "regex", + payload: !checked, + }); + searchInputRef.current?.focus(); + }} + /> + + } + /> + { + appDispatch(closeSearch()); + }} + /> +
    + {query.replacing ? ( +
    + + searchDispatch({ + type: "replaceTerm", + payload: evt.target.value, + }) + } + placeholder="Replace" + icons={ + { + searchDispatch({ + type: "preserveCase", + payload: !checked, + }); + replaceInputRef.current?.focus(); + }} + /> + } + /> + { + if ( + !window.confirm( + `Would you like to replace all instances of ${query.searchTerm} with ${query.replaceTerm}?` + ) + ) { + return; + } + + await Promise.all( + results.flatMap(({ model, file, matches }) => + replace(model, file, matches) + ) ?? [] + ); + }} + /> +
    + ) : null} +
    +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.css b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.css new file mode 100644 index 0000000..075caf0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.css @@ -0,0 +1,48 @@ +.HashCoreFilesSearchInput { + flex: 1; +} + +.HashCoreFilesSearchInput__search { + padding-right: 57px; +} + +.HashCoreFilesSearchInput__replace { + padding-right: 35px; +} + +.HashCoreFilesSearchInput__Icons { + display: flex; + align-items: center; + padding: 0 0.5rem 0 0; + color: var(--theme-grey); + position: absolute; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.HashCoreFilesSearchInput__Icons > * { + pointer-events: auto; +} + +.HashCoreFilesSearchInput__Icons .codicon-case-sensitive:before { + margin-left: -1px; +} + +.HashCoreFilesSearchInput__Icons .checked { + background-color: var(--theme-blue-translucent); + color: white; +} + +.HashCoreFilesSearchInput .HashCoreFilesSearchFade { + top: 1px; + bottom: 1px; +} + +.HashCoreFilesSearchInput + input:focus + + .HashCoreFilesSearchInput__Icons + .HashCoreFilesSearchFade { + opacity: 0; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.tsx new file mode 100644 index 0000000..a6fe76c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchInput.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef, ReactNode } from "react"; +import classnames from "classnames"; + +import { HashCoreFilesSearchFade } from "./HashCoreFilesSearchFade"; +import { + RoundedTextInput, + RoundedTextInputProps, +} from "../../../Inputs/RoundedTextInput"; + +import "./HashCoreFilesSearchInput.css"; + +/** + * Using a named function so the name shows up in dev tools despite forwardRef + */ +export const HashCoreFilesSearchInput = forwardRef< + HTMLInputElement, + RoundedTextInputProps & { icons: ReactNode } +>(function SearchInput({ icons, className, ...props }, ref) { + return ( + +
    + + {icons} +
    +
    + ); +}); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.scss new file mode 100644 index 0000000..d92dfcf --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.scss @@ -0,0 +1,24 @@ +.HashCoreFilesSearchItemWithIcons { + position: relative; + width: 100%; + overflow: hidden; +} + +.HashCoreFilesSearchItemWithIcons__Icons { + position: absolute; + top: 0; + right: 0; + bottom: 0; + opacity: 0; + background-color: var(--background-color); + font-size: 1rem; + display: flex; + align-items: center; + padding-right: 5px; + + @media (any-hover: hover) { + .HashCoreFilesSearchItemWithIcons:hover & { + opacity: 1; + } + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.tsx new file mode 100644 index 0000000..cc5a199 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchItemWithIcons.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef, HTMLAttributes, ReactNode } from "react"; + +import { HashCoreFilesSearchFade } from "./HashCoreFilesSearchFade"; + +import "./HashCoreFilesSearchItemWithIcons.scss"; + +type HashCoreFilesSearchItemWithIconsProps = HTMLAttributes & { + icons: ReactNode; +}; + +/** + * Using a named function so the name shows up in dev tools despite forwardRef + */ +export const HashCoreFilesSearchItemWithIcons = forwardRef< + HTMLDivElement, + HashCoreFilesSearchItemWithIconsProps +>(function HashCoreFilesSearchItemWithIcons( + { children, icons, ...props }, + ref +) { + return ( +
    +
    + {children} +
    + {icons ? ( +
    + + {icons} +
    + ) : null} +
    + ); +}); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.scss new file mode 100644 index 0000000..c7aad21 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.scss @@ -0,0 +1,41 @@ +.HashCoreFilesSearchMatch { + border-radius: 3px; + user-select: none; + overflow: hidden; + white-space: nowrap; + cursor: pointer; + width: 100%; + box-sizing: border-box; + padding-left: 10px; + + /* styles borrowed from monaco */ + font-family: Menlo, Monaco, "Courier New", monospace; + font-weight: normal; + font-size: 12px; + + @media (any-hover: hover) { + &:hover { + --background-color: var(--theme-light-on-dark); + background-color: var(--background-color); + } + } +} + +.HashCoreFilesSearchMatch__MatchedText { + background-color: rgba(255, 255, 255, 0.15); +} + +.HashCoreFilesSearchMatch__MatchDiff { + border: 1px solid; +} + +.HashCoreFilesSearchMatch__MatchDiff--Before { + border-color: var(--theme-red); + background-color: var(--theme-red-translucent); + text-decoration: line-through; +} + +.HashCoreFilesSearchMatch__MatchDiff--After { + border-color: var(--theme-green); + background-color: var(--theme-green-translucent); +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.tsx new file mode 100644 index 0000000..9263439 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchMatch.tsx @@ -0,0 +1,75 @@ +import React, { FC } from "react"; + +import { HashCoreFilesSearchItemWithIcons } from "./HashCoreFilesSearchItemWithIcons"; +import { MonacoIconButton } from "./MonacoIconComponents"; +import type { SearchMatch } from "./types"; +import { useKeepInView } from "../../../KeepInView"; + +import "./HashCoreFilesSearchMatch.scss"; + +type SearchResultProps = { + match: SearchMatch; + onClick: (match: SearchMatch) => void; + onReplace: (match: SearchMatch) => Promise; + replacing: boolean; + pending: boolean; +}; + +export const HashCoreFilesSearchMatch: FC = ({ + match, + onClick, + onReplace, + replacing, + pending, +}) => { + const [parentRef, childRef] = useKeepInView(); + + return ( +
    { + evt.preventDefault(); + + await onClick(match); + }} + > + { + await onReplace(match); + }} + /> + ) : null + } + style={{ overflow: "hidden" }} + ref={parentRef} + > + {match.beforeText} + + {replacing ? ( + <> + + {match.matchedText} + + {match.replaceTerm ? ( + + {match.replaceTerm} + + ) : null} + + ) : ( + + {match.matchedText} + + )} + + {match.afterText} + +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.css b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.css new file mode 100644 index 0000000..777ae7f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.css @@ -0,0 +1,58 @@ +.HashCoreFilesSearchProgress { + position: absolute; + height: 3px; + top: 0; + left: 0; + background: var(--theme-blue); + width: 0; + opacity: 0; + transition: opacity 0.2s ease; +} + +.HashCoreFilesSearchProgress.active:not(.complete) { + animation: 5s HashCoreFilesSearchProgress--Active; + opacity: 1; + animation-fill-mode: forwards; +} + +.HashCoreFilesSearchProgress.complete { + animation: 0.4s HashCoreFilesSearchProgress--Complete; +} + +@keyframes HashCoreFilesSearchProgress--Active { + 0% { + width: 0; + } + 25% { + width: 45%; + } + 50% { + width: 67%; + } + 75% { + width: 78%; + } + 87% { + width: 84%; + } + 93% { + width: 87%; + } + 100% { + width: 90%; + } +} + +@keyframes HashCoreFilesSearchProgress--Complete { + 0% { + opacity: 1; + } + 50% { + width: 100%; + opacity: 1; + } + 100% { + width: 100%; + opacity: 0; + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.tsx new file mode 100644 index 0000000..87a8210 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/HashCoreFilesSearchProgress.tsx @@ -0,0 +1,45 @@ +import React, { FC, useEffect, useRef } from "react"; +import classnames from "classnames"; + +import "./HashCoreFilesSearchProgress.css"; + +export const HashCoreFilesSearchProgress: FC<{ searching: boolean }> = ({ + searching, +}) => { + const progressRef = useRef(null); + + useEffect(() => { + const progress = progressRef.current!; + const currentlySearching = progress.classList.contains("active"); + + if (currentlySearching === searching) { + return; + } + + if (searching) { + progress.classList.remove("complete"); + progress.classList.add("active"); + } else { + progress.style.width = `${progress.offsetWidth}px`; + progress.classList.remove("active"); + progress.classList.add("complete"); + } + }, [searching]); + + return ( +
    { + if (evt.animationName.includes("Complete")) { + const progress = progressRef.current!; + + progress.style.width = ""; + progress.classList.remove("complete"); + progress.classList.remove("complete"); + } + }} + /> + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.scss b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.scss new file mode 100644 index 0000000..9d8f3f2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.scss @@ -0,0 +1,30 @@ +.HashCoreFilesSearch .codicon { + transition: all 0.2s ease; +} + +.HashCoreFilesSearch .button { + color: white; + user-select: none; +} + +.HashCoreFilesSearch .button.disabled { + color: var(--theme-dark-grey); +} + +.HashCoreFilesSearch .button:not(.disabled) { + cursor: pointer; + + @media (any-hover: hover) { + &:hover { + background-color: var(--theme-blue-translucent); + } + } +} + +.HashCoreFilesSearch .codicon-search-replace { + padding-left: 1px; +} + +.HashCoreFilesSearch .codicon-search-replace:before { + content: "\eb3d"; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.tsx new file mode 100644 index 0000000..b973397 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/MonacoIconComponents.tsx @@ -0,0 +1,91 @@ +import React, { FC, HTMLProps } from "react"; +import classnames from "classnames"; + +import "./MonacoIconComponents.scss"; + +type MonacoIconProps = Omit, "title" | "onClick"> & { + iconName: string; + title: string; + onClick: VoidFunction; +}; + +const MonacoIcon: FC = ({ + className, + onClick, + role, + title, + iconName, + disabled, + ...props +}) => ( +
    { + evt.preventDefault(); + evt.stopPropagation(); + if (!disabled) { + onClick(); + } + }} + /> +); + +export const MonacoIconButton: FC> = ({ + title, + iconName, + className, + ...props +}) => ( + +); + +export const MonacoIconCheckbox: FC< + Omit & { + checked: boolean; + onClick: (checked: boolean) => void; + } +> = ({ checked, title, onClick, className, ...props }) => ( + { + onClick(checked); + }} + aria-checked={checked} + /> +); + +export const MonacoIconToggle: FC< + Omit & { + open: boolean; + onClick: (open: boolean) => void; + } +> = ({ open, onClick, className, ...props }) => ( + { + onClick(open); + }} + aria-expanded={open} + /> +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/hooks.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/hooks.ts new file mode 100644 index 0000000..eeee61c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/hooks.ts @@ -0,0 +1,394 @@ +import { RefObject, useCallback, useEffect, useMemo, useRef } from "react"; +import { useDispatch, useSelector, useStore } from "react-redux"; +import produce from "immer"; +import { IRange, editor } from "monaco-editor"; +import { Observable, Subject, merge } from "rxjs"; +import { + buffer, + distinctUntilChanged, + filter, + map, + pairwise, +} from "rxjs/operators"; + +import type { AppDispatch, RootState } from "../../../../features/types"; +import type { HcFile } from "../../../../features/files/types"; +import { + Replacement, + SearchFileResult, + SearchQuery, + SearchResultsDictionary, +} from "./types"; +import { SearchDispatch, SearchState } from "./reducer"; +import { fromStore } from "../../../../util/fromStore"; +import { getDiffModel } from "../../../TabbedEditor/DiffPanel"; +import { getNextContents, searchDebounce, triggerSearch } from "./util"; +import { isReadOnly } from "../../../../features/files/utils"; +import { parseReplaceString } from "./monaco"; +import { + selectAllFiles, + selectFileEntities, + selectFileIds, + selectReplaceProposal, +} from "../../../../features/files/selectors"; +import { selectCurrentProjectUrl } from "../../../../features/project/selectors"; +import { + setCurrentFileId, + setReplaceProposal, +} from "../../../../features/files/slice"; +import { setMonacoModel } from "../../../../features/monaco"; +import { useMonacoContainerFromContext } from "../../../TabbedEditor/hooks"; + +const useFileChangeObservable = () => { + const store = useStore(); + + return useMemo(() => { + const observable = new Observable((subscriber) => { + const cache = new Map(); + + const emitChangedFiles = (state: RootState) => { + const files = selectAllFiles(state); + + for (const file of files) { + if (cache.get(file.id) !== file.contents) { + cache.set(file.id, file.contents); + + subscriber.next(file.id); + } + } + }; + + const unsubscribeStore = store.subscribe(() => { + const state = store.getState(); + const ids = selectFileIds(state); + + for (const key of cache.keys()) { + if (!ids.includes(key)) { + cache.delete(key); + } + } + + emitChangedFiles(state); + }); + + emitChangedFiles(store.getState()); + + return () => { + unsubscribeStore(); + }; + }); + + return observable.pipe(buffer(observable.pipe(searchDebounce()))); + }, [store]); +}; + +export const useFilesRemovedObservable = () => { + const store = useStore(); + return useMemo(() => { + return fromStore(store).pipe( + map(selectFileIds), + distinctUntilChanged(), + pairwise(), + map(([firstIds, secondIds]) => + firstIds + .filter((id) => !secondIds.includes(id)) + .map((id) => id.toString()) + ), + filter((ids) => ids.length > 0) + ); + }, [store]); +}; + +const useQueryChangeObservable = (query: SearchQuery) => { + const store = useStore(); + const subject = useMemo(() => new Subject(), []); + + useEffect(() => { + subject.next(query); + }, [subject, query]); + + return useMemo( + () => + subject.pipe( + searchDebounce(), + map(() => selectFileIds(store.getState()) as string[]) + ), + [subject, store] + ); +}; + +const useRemoveDeletedFilesFromResults = ( + resultsRef: RefObject, + searchDispatch: SearchDispatch +) => { + const fileIds = useSelector(selectFileIds); + + useEffect(() => { + if (!resultsRef.current) { + return; + } + + const keysToRemove = Object.keys(resultsRef.current).filter( + (resultFileId) => !fileIds.includes(resultFileId) + ); + + if (keysToRemove.length) { + searchDispatch({ + type: "results", + payload: produce(resultsRef.current, (draft) => { + for (const key of keysToRemove) { + delete draft[key]; + } + }), + }); + } + }, [fileIds, resultsRef, searchDispatch]); +}; + +const useFilesToSearchObserver = (searchState: SearchState) => { + const fileChangeObservable = useFileChangeObservable(); + const queryChangeObservable = useQueryChangeObservable(searchState.query); + + return useMemo(() => merge(fileChangeObservable, queryChangeObservable), [ + fileChangeObservable, + queryChangeObservable, + ]); +}; + +export const useSearch = ( + searchState: SearchState, + searchDispatch: SearchDispatch +) => { + const resultsRef = useRef(searchState.resultsMap); + const queryRef = useRef(searchState.query); + + useEffect(() => { + resultsRef.current = searchState.resultsMap; + queryRef.current = searchState.query; + }); + + const store = useStore(); + const filesToSearchObserver = useFilesToSearchObserver(searchState); + + const projectUrl = useSelector(selectCurrentProjectUrl); + + useRemoveDeletedFilesFromResults(resultsRef, searchDispatch); + + useEffect(() => { + if (queryRef.current.searchTerm) { + searchDispatch({ type: "pending" }); + } + }, [searchDispatch]); + + useEffect(() => { + let controller: AbortController | null = null; + + const subscription = filesToSearchObserver.subscribe((filesToSearch) => { + controller?.abort(); + + const query = queryRef.current; + + /** + * We don't want to do the search if we don't have a search term, but we + * didn't filter the event from the observer because we do want to ensure + * we abort the pending search + */ + if (!query.searchTerm) { + controller = null; + + return; + } + + controller = new AbortController(); + + const files = selectFileEntities(store.getState()); + + // This parses the replace term for any regex group tokens ($1, $2, etc) + const pattern = query.replaceTerm + ? parseReplaceString(query.replaceTerm) + : null; + + triggerSearch( + query, + filesToSearch, + projectUrl, + files, + pattern, + resultsRef.current, + controller.signal + ) + .then((nextResults) => { + if (controller!.signal.aborted) { + throw new Error("Aborted"); + } + + controller = null; + searchDispatch({ + type: "results", + payload: nextResults, + }); + }) + .catch((err) => { + if (err.message !== "Aborted") { + throw err; + } + }); + }); + + return () => { + controller?.abort(); + subscription.unsubscribe(); + }; + }, [filesToSearchObserver, projectUrl, searchDispatch, store]); +}; + +/** + * Highlights the search term in the editor + */ +export const useMonacoSearchHighlightDecorator = ( + results: SearchFileResult[] +) => { + useEffect(() => { + if (!results.length) return; + + const newDecorationsWithModel = results.map( + ({ matches, model }) => + [ + model, + model.deltaDecorations( + [], + matches.map(({ range }) => ({ + range, + options: { + className: "findMatch", + isWholeLine: false, + stickiness: + editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }, + })) + ), + ] as const + ); + + return () => { + for (const [model, decorations] of newDecorationsWithModel) { + // It may have been disposed by this point – if we've changed project + if (!model.isDisposed()) { + model.deltaDecorations(decorations, []); + } + } + }; + }, [results]); +}; + +export const useReplaceProposal = ( + replacing: boolean, + results: SearchFileResult[] +) => { + const appDispatch = useDispatch(); + const replaceProposal = useSelector(selectReplaceProposal); + + const replacingFileId = replaceProposal.proposal?.fileId; + const replacingFileIdRef = useRef(replacingFileId); + + useEffect(() => { + replacingFileIdRef.current = replacingFileId; + }); + + /** + * This effect ensures the nextContents of the replaceProposal stays up to + * date as files/search results changes + */ + useEffect(() => { + if (!replacingFileIdRef.current || !replacing) { + return; + } + + const resultsForCurrentFile = results.find( + ({ file }) => file.id === replacingFileIdRef.current + ); + + if (!resultsForCurrentFile) { + appDispatch(setReplaceProposal(null)); + return; + } + + const { file, model, matches } = resultsForCurrentFile; + + if (isReadOnly(file, true)) { + throw new Error("Found read only file in replaceProposal"); + } + + appDispatch( + setReplaceProposal({ + fileId: file.id, + nextContents: getNextContents(file, model, matches), + }) + ); + }, [appDispatch, replacing, results]); + + /** + * This effect removes the visible replace proposal tab when swapping from + * replace mode to search mode, or when exiting search + */ + useEffect(() => { + if (replacing) { + return () => { + if (replacingFileIdRef.current) { + appDispatch(setReplaceProposal(null)); + } + }; + } + }, [appDispatch, replacing]); +}; + +export const useRevealMatchInEditor = () => { + const projectUrl = useSelector(selectCurrentProjectUrl); + const [editorInstance] = useMonacoContainerFromContext(); + const [diffEditorInstance] = useMonacoContainerFromContext(true); + const appDispatch = useDispatch(); + + return useCallback( + ( + replacing: boolean, + file: HcFile, + model: editor.ITextModel, + matches: Replacement[], + range?: IRange + ) => { + /** + * We have to manually set the model here because + * the effect that normally does this for us won't + * yet have fired, and we need to set the scroll + * position. + */ + if (replacing) { + if (!diffEditorInstance) { + throw new Error("Cannot find editor instance to reveal file in"); + } + const nextContents = getNextContents(file, model, matches); + + appDispatch(setReplaceProposal({ fileId: file.id, nextContents })); + diffEditorInstance.setModel( + getDiffModel(projectUrl, file, nextContents) + ); + + if (range) { + diffEditorInstance.revealRangeInCenter(range); + } + } else { + if (!editorInstance) { + throw new Error("Cannot find editor instance to reveal file in"); + } + appDispatch(setCurrentFileId(file.id)); + + setMonacoModel(editorInstance, model); + + if (range) { + editorInstance.revealRangeInCenter(range); + } + } + }, + [appDispatch, diffEditorInstance, editorInstance, projectUrl] + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/index.ts new file mode 100644 index 0000000..1f630ce --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/index.ts @@ -0,0 +1,2 @@ +export { HashCoreFilesSearch } from "./HashCoreFilesSearch"; +export { HashCoreFilesSearchContainer } from "./HashCoreFilesSearchContainer"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/README.md b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/README.md new file mode 100644 index 0000000..3f0ea46 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/README.md @@ -0,0 +1,4 @@ +Code in this directory is from the vscode project, but is not exported in any NPM package. + +The duplication here is regrettable but unavoidable, and it does gain us the ability to update monaco without worrying +about an internal/private API breaking. diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/charCode.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/charCode.ts new file mode 100644 index 0000000..ad3a482 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/charCode.ts @@ -0,0 +1,37 @@ +// Adapted from https://github.com/microsoft/vscode/blob/a1de2a783afd8c9e64d3ddbf517df727f9f6cdef/src/vs/base/common/charCode.ts +// To keep the bundle size small, this is just a subset needed for our purposes + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + */ +export enum CharCode { + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `&` character. + */ + Ampersand = 38, + + Digit0 = 48, + Digit1 = 49, + Digit9 = 57, + + L = 76, + U = 85, + + Backslash = 92, + + l = 108, + n = 110, + t = 116, + u = 117, +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/index.ts new file mode 100644 index 0000000..617924c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/index.ts @@ -0,0 +1 @@ +export { parseReplaceString, ReplacePattern } from "./replacePattern"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/replacePattern.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/replacePattern.ts new file mode 100644 index 0000000..b92bd86 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/replacePattern.ts @@ -0,0 +1,373 @@ +// Adapted from https://github.com/microsoft/vscode/blob/a1de2a783afd8c9e64d3ddbf517df727f9f6cdef/src/vs/editor/contrib/find/replacePattern.ts + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CharCode } from "./charCode"; +import { buildReplaceStringWithCasePreserved } from "./search"; + +enum ReplacePatternKind { + StaticValue = 0, + DynamicPieces = 1, +} + +/** + * Assigned when the replace pattern is entirely static. + */ +class StaticValueReplacePattern { + public readonly kind = ReplacePatternKind.StaticValue; + constructor(public readonly staticValue: string) {} +} + +/** + * Assigned when the replace pattern has replacement patterns. + */ +class DynamicPiecesReplacePattern { + public readonly kind = ReplacePatternKind.DynamicPieces; + constructor(public readonly pieces: ReplacePiece[]) {} +} + +export class ReplacePattern { + public static fromStaticValue(value: string): ReplacePattern { + return new ReplacePattern([ReplacePiece.staticValue(value)]); + } + + private readonly _state: + | StaticValueReplacePattern + | DynamicPiecesReplacePattern; + + public get hasReplacementPatterns(): boolean { + return this._state.kind === ReplacePatternKind.DynamicPieces; + } + + constructor(pieces: ReplacePiece[] | null) { + if (!pieces || pieces.length === 0) { + this._state = new StaticValueReplacePattern(""); + } else if (pieces.length === 1 && pieces[0].staticValue !== null) { + this._state = new StaticValueReplacePattern(pieces[0].staticValue); + } else { + this._state = new DynamicPiecesReplacePattern(pieces); + } + } + + public buildReplaceString( + matches: string[] | null, + preserveCase?: boolean + ): string { + if (this._state.kind === ReplacePatternKind.StaticValue) { + if (preserveCase) { + return buildReplaceStringWithCasePreserved( + matches, + this._state.staticValue + ); + } else { + return this._state.staticValue; + } + } + + let result = ""; + for (let idx = 0, len = this._state.pieces.length; idx < len; idx++) { + const piece = this._state.pieces[idx]; + if (piece.staticValue !== null) { + // static value ReplacePiece + result += piece.staticValue; + continue; + } + + // match index ReplacePiece + let match: string = ReplacePattern._substitute(piece.matchIndex, matches); + if (piece.caseOps !== null && piece.caseOps.length > 0) { + const repl: string[] = []; + const lenOps: number = piece.caseOps.length; + let opIdx: number = 0; + for ( + let idx: number = 0, len: number = match.length; + idx < len; + idx++ + ) { + if (opIdx >= lenOps) { + repl.push(match.slice(idx)); + break; + } + switch (piece.caseOps[opIdx]) { + case "U": + repl.push(match[idx].toUpperCase()); + break; + case "u": + repl.push(match[idx].toUpperCase()); + opIdx++; + break; + case "L": + repl.push(match[idx].toLowerCase()); + break; + case "l": + repl.push(match[idx].toLowerCase()); + opIdx++; + break; + default: + repl.push(match[idx]); + } + } + match = repl.join(""); + } + result += match; + } + + return result; + } + + private static _substitute( + matchIndex: number, + matches: string[] | null + ): string { + if (matches === null) { + return ""; + } + if (matchIndex === 0) { + return matches[0]; + } + + let remainder = ""; + while (matchIndex > 0) { + if (matchIndex < matches.length) { + // A match can be undefined + const match = matches[matchIndex] || ""; + return match + remainder; + } + remainder = String(matchIndex % 10) + remainder; + matchIndex = Math.floor(matchIndex / 10); + } + return "$" + remainder; + } +} + +/** + * A replace piece can either be a static string or an index to a specific match. + */ +export class ReplacePiece { + public static staticValue(value: string): ReplacePiece { + return new ReplacePiece(value, -1, null); + } + + public static matchIndex(index: number): ReplacePiece { + return new ReplacePiece(null, index, null); + } + + public static caseOps(index: number, caseOps: string[]): ReplacePiece { + return new ReplacePiece(null, index, caseOps); + } + + public readonly staticValue: string | null; + public readonly matchIndex: number; + public readonly caseOps: string[] | null; + + private constructor( + staticValue: string | null, + matchIndex: number, + caseOps: string[] | null + ) { + this.staticValue = staticValue; + this.matchIndex = matchIndex; + if (!caseOps || caseOps.length === 0) { + this.caseOps = null; + } else { + this.caseOps = caseOps.slice(0); + } + } +} + +class ReplacePieceBuilder { + private readonly _source: string; + private _lastCharIndex: number; + private readonly _result: ReplacePiece[]; + private _resultLen: number; + private _currentStaticPiece: string; + + constructor(source: string) { + this._source = source; + this._lastCharIndex = 0; + this._result = []; + this._resultLen = 0; + this._currentStaticPiece = ""; + } + + public emitUnchanged(toCharIndex: number): void { + this._emitStatic(this._source.substring(this._lastCharIndex, toCharIndex)); + this._lastCharIndex = toCharIndex; + } + + public emitStatic(value: string, toCharIndex: number): void { + this._emitStatic(value); + this._lastCharIndex = toCharIndex; + } + + private _emitStatic(value: string): void { + if (value.length === 0) { + return; + } + this._currentStaticPiece += value; + } + + public emitMatchIndex( + index: number, + toCharIndex: number, + caseOps: string[] + ): void { + if (this._currentStaticPiece.length !== 0) { + this._result[this._resultLen++] = ReplacePiece.staticValue( + this._currentStaticPiece + ); + this._currentStaticPiece = ""; + } + this._result[this._resultLen++] = ReplacePiece.caseOps(index, caseOps); + this._lastCharIndex = toCharIndex; + } + + public finalize(): ReplacePattern { + this.emitUnchanged(this._source.length); + if (this._currentStaticPiece.length !== 0) { + this._result[this._resultLen++] = ReplacePiece.staticValue( + this._currentStaticPiece + ); + this._currentStaticPiece = ""; + } + return new ReplacePattern(this._result); + } +} + +/** + * \n => inserts a LF + * \t => inserts a TAB + * \\ => inserts a "\". + * \u => upper-cases one character in a match. + * \U => upper-cases ALL remaining characters in a match. + * \l => lower-cases one character in a match. + * \L => lower-cases ALL remaining characters in a match. + * $$ => inserts a "$". + * $& and $0 => inserts the matched substring. + * $n => Where n is a non-negative integer lesser than 100, inserts the nth parenthesized submatch string + * everything else stays untouched + * + * Also see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Specifying_a_string_as_a_parameter + */ +export function parseReplaceString(replaceString: string): ReplacePattern { + if (!replaceString || replaceString.length === 0) { + return new ReplacePattern(null); + } + + const caseOps: string[] = []; + const result = new ReplacePieceBuilder(replaceString); + + for (let idx = 0, len = replaceString.length; idx < len; idx++) { + const chCode = replaceString.charCodeAt(idx); + + if (chCode === CharCode.Backslash) { + // move to next char + idx++; + + if (idx >= len) { + // string ends with a \ + break; + } + + const nextChCode = replaceString.charCodeAt(idx); + // let replaceWithCharacter: string | null = null; + + switch (nextChCode) { + case CharCode.Backslash: + // \\ => inserts a "\" + result.emitUnchanged(idx - 1); + result.emitStatic("\\", idx + 1); + break; + case CharCode.n: + // \n => inserts a LF + result.emitUnchanged(idx - 1); + result.emitStatic("\n", idx + 1); + break; + case CharCode.t: + // \t => inserts a TAB + result.emitUnchanged(idx - 1); + result.emitStatic("\t", idx + 1); + break; + // Case modification of string replacements, patterned after Boost, but only applied + // to the replacement text, not subsequent content. + case CharCode.u: + // \u => upper-cases one character. + case CharCode.U: + // \U => upper-cases ALL following characters. + case CharCode.l: + // \l => lower-cases one character. + case CharCode.L: + // \L => lower-cases ALL following characters. + result.emitUnchanged(idx - 1); + result.emitStatic("", idx + 1); + caseOps.push(String.fromCharCode(nextChCode)); + break; + } + + continue; + } + + if (chCode === CharCode.DollarSign) { + // move to next char + idx++; + + if (idx >= len) { + // string ends with a $ + break; + } + + const nextChCode = replaceString.charCodeAt(idx); + + if (nextChCode === CharCode.DollarSign) { + // $$ => inserts a "$" + result.emitUnchanged(idx - 1); + result.emitStatic("$", idx + 1); + continue; + } + + if (nextChCode === CharCode.Digit0 || nextChCode === CharCode.Ampersand) { + // $& and $0 => inserts the matched substring. + result.emitUnchanged(idx - 1); + result.emitMatchIndex(0, idx + 1, caseOps); + caseOps.length = 0; + continue; + } + + if (CharCode.Digit1 <= nextChCode && nextChCode <= CharCode.Digit9) { + // $n + + let matchIndex = nextChCode - CharCode.Digit0; + + // peek next char to probe for $nn + if (idx + 1 < len) { + const nextNextChCode = replaceString.charCodeAt(idx + 1); + if ( + CharCode.Digit0 <= nextNextChCode && + nextNextChCode <= CharCode.Digit9 + ) { + // $nn + + // move to next char + idx++; + matchIndex = matchIndex * 10 + (nextNextChCode - CharCode.Digit0); + + result.emitUnchanged(idx - 2); + result.emitMatchIndex(matchIndex, idx + 1, caseOps); + caseOps.length = 0; + continue; + } + } + + result.emitUnchanged(idx - 1); + result.emitMatchIndex(matchIndex, idx + 1, caseOps); + caseOps.length = 0; + continue; + } + } + } + + return result.finalize(); +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/search.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/search.ts new file mode 100644 index 0000000..c2d2673 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/search.ts @@ -0,0 +1,85 @@ +// Adapted from https://github.com/microsoft/vscode/blob/a1de2a783afd8c9e64d3ddbf517df727f9f6cdef/src/vs/base/common/search.ts + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { containsUppercaseCharacter } from "./strings"; + +export function buildReplaceStringWithCasePreserved( + matches: string[] | null, + pattern: string +): string { + if (matches && matches[0] !== "") { + const containsHyphens = validateSpecificSpecialCharacter( + matches, + pattern, + "-" + ); + const containsUnderscores = validateSpecificSpecialCharacter( + matches, + pattern, + "_" + ); + if (containsHyphens && !containsUnderscores) { + return buildReplaceStringForSpecificSpecialCharacter( + matches, + pattern, + "-" + ); + } else if (!containsHyphens && containsUnderscores) { + return buildReplaceStringForSpecificSpecialCharacter( + matches, + pattern, + "_" + ); + } + if (matches[0].toUpperCase() === matches[0]) { + return pattern.toUpperCase(); + } else if (matches[0].toLowerCase() === matches[0]) { + return pattern.toLowerCase(); + } else if (containsUppercaseCharacter(matches[0][0])) { + return pattern[0].toUpperCase() + pattern.substr(1); + } else { + // we don't understand its pattern yet. + return pattern; + } + } else { + return pattern; + } +} + +function validateSpecificSpecialCharacter( + matches: string[], + pattern: string, + specialCharacter: string +): boolean { + const doesContainSpecialCharacter = + matches[0].indexOf(specialCharacter) !== -1 && + pattern.indexOf(specialCharacter) !== -1; + return ( + doesContainSpecialCharacter && + matches[0].split(specialCharacter).length === + pattern.split(specialCharacter).length + ); +} + +function buildReplaceStringForSpecificSpecialCharacter( + matches: string[], + pattern: string, + specialCharacter: string +): string { + const splitPatternAtSpecialCharacter = pattern.split(specialCharacter); + const splitMatchAtSpecialCharacter = matches[0].split(specialCharacter); + let replaceString: string = ""; + splitPatternAtSpecialCharacter.forEach((splitValue, index) => { + replaceString += + buildReplaceStringWithCasePreserved( + [splitMatchAtSpecialCharacter[index]], + splitValue + ) + specialCharacter; + }); + + return replaceString.slice(0, -1); +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/strings.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/strings.ts new file mode 100644 index 0000000..c3a074d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/monaco/strings.ts @@ -0,0 +1,11 @@ +// Adapted from https://github.com/microsoft/vscode/blob/a1de2a783afd8c9e64d3ddbf517df727f9f6cdef/src/vs/base/common/strings.ts +// To keep the bundle small, this contains just the functions we need + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function containsUppercaseCharacter(target: string): boolean { + return !target ? false : target.toLowerCase() !== target; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/reducer.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/reducer.ts new file mode 100644 index 0000000..7d60d21 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/reducer.ts @@ -0,0 +1,208 @@ +import { Dispatch, useEffect, useReducer } from "react"; +import { useSelector } from "react-redux"; +import produce, { Draft } from "immer"; + +import { + SearchFileResult, + SearchQuery, + SearchResultsDictionary, +} from "./types"; +import { selectCurrentProjectUrl } from "../../../../features/project/selectors"; +import { useFilesRemovedObservable } from "./hooks"; + +export type SearchAction = + | { + type: "searchTerm"; + payload: SearchQuery["searchTerm"]; + } + | { + type: "replaceTerm"; + payload: SearchQuery["replaceTerm"]; + } + | { + type: "replacing"; + payload: SearchQuery["replacing"]; + } + | { + type: "caseSensitive"; + payload: SearchQuery["caseSensitive"]; + } + | { + type: "regex"; + payload: SearchQuery["regex"]; + } + | { type: "preserveCase"; payload: SearchQuery["preserveCase"] } + | { type: "results"; payload: SearchResultsDictionary } + | { type: "pending" } + | { type: "reset"; payload: { projectUrl: string | null } } + | { type: "filesRemoved"; payload: string[] }; + +export type SearchState = { + query: SearchQuery; + resultsMap: SearchResultsDictionary; + results: SearchFileResult[]; + noResults: boolean; + pending: boolean; + projectUrl: string | null; +}; + +export type SearchDispatch = Dispatch; + +const searchInitialState: SearchState = { + query: { + caseSensitive: false, + regex: false, + replacing: false, + searchTerm: "", + replaceTerm: "", + preserveCase: false, + }, + pending: false, + noResults: false, + resultsMap: {}, + results: [], + projectUrl: null, +}; + +/** + * We store search results as a dictionary of the file id against the an object + * representing the search results (so that we can incrementally update it), + * however, most consumers will want results as an iterable array. Rather than + * computing this wherever we need it, we have what is essentially a computed + * property that we update whenever we set results. + */ +const setResults = ( + state: Draft, + results: SearchResultsDictionary | null +) => { + state.resultsMap = results ?? {}; + state.results = results + ? Object.values(results).filter((result) => result.matches.length > 0) + : []; + state.pending = false; + state.noResults = results ? state.results.length === 0 : false; +}; + +/** + * Typing explicitly because immer combined with useReducer seems to result in + * some odd typing issues. + */ +const searchReducer: ( + state: SearchState, + action: SearchAction +) => SearchState = produce( + (state: Draft, action: SearchAction) => { + if (action.type === "reset") { + return { + ...searchInitialState, + projectUrl: action.payload.projectUrl, + }; + } + + const originalPending = state.pending; + + state.pending = !!(action.type === "searchTerm" + ? action.payload + : state.query.searchTerm); + + switch (action.type) { + case "caseSensitive": + if (action.payload !== state.query.caseSensitive) { + state.query.caseSensitive = action.payload; + state.pending = !!state.query.searchTerm; + } + break; + + case "pending": + state.pending = true; + break; + + case "regex": + if (action.payload !== state.query.regex) { + state.query.regex = action.payload; + state.pending = !!state.query.searchTerm; + } + break; + + case "replacing": + if (action.payload !== state.query.replacing) { + state.query.replacing = action.payload; + state.pending = !!state.query.searchTerm; + state.query.replaceTerm = ""; + } + break; + + case "replaceTerm": + if (action.payload !== state.query.replaceTerm) { + state.query.replaceTerm = action.payload; + state.pending = !!state.query.searchTerm; + } + break; + + case "searchTerm": + if (action.payload !== state.query.searchTerm) { + state.query.searchTerm = action.payload; + + if (action.payload) { + state.pending = true; + } else { + setResults(state, null); + } + } + break; + + case "preserveCase": + if (action.payload !== state.query.preserveCase) { + state.query.preserveCase = action.payload; + state.pending = !!state.query.searchTerm; + } + break; + + case "results": + setResults(state, action.payload); + break; + + case "filesRemoved": + state.pending = originalPending; + + if (state.results.length > 0) { + for (const id of action.payload) { + if (state.resultsMap[id]) { + delete state.resultsMap[id]; + } + } + + setResults(state, state.resultsMap); + if (state.noResults && !state.query.searchTerm) { + state.noResults = false; + } + } + } + } +); + +export const useSearchReducer = () => { + const [searchState, searchDispatch] = useReducer( + searchReducer, + searchInitialState + ); + + const projectUrl = useSelector(selectCurrentProjectUrl); + if (searchState.projectUrl !== projectUrl) { + searchDispatch({ type: "reset", payload: { projectUrl } }); + } + + const filesRemovedObserver = useFilesRemovedObservable(); + + useEffect(() => { + const subscription = filesRemovedObserver.subscribe((ids) => { + searchDispatch({ type: "filesRemoved", payload: ids }); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [filesRemovedObserver]); + + return [searchState, searchDispatch] as const; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/types.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/types.ts new file mode 100644 index 0000000..b74304f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/types.ts @@ -0,0 +1,32 @@ +import { Range, editor } from "monaco-editor"; + +import type { HcFile } from "../../../../features/files/types"; + +export type SearchQuery = { + searchTerm: string; + replacing: boolean; + replaceTerm: string; + caseSensitive: boolean; + regex: boolean; + preserveCase: boolean; +}; + +export type Replacement = { range: Range; replaceTerm: string }; + +export type SearchMatch = Replacement & { + id: string; + beforeText: string; + matchedText: string; + afterText: string; +}; + +export type SearchFileResult = { + file: HcFile; + model: editor.ITextModel; + matches: SearchMatch[]; + replacing: boolean; +}; + +export type SearchResultsDictionary = { + [index: string]: SearchFileResult; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/Search/util.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/util.ts new file mode 100644 index 0000000..a1eca06 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/Search/util.ts @@ -0,0 +1,174 @@ +import { Dictionary } from "@reduxjs/toolkit"; +import produce from "immer"; +import { Range, editor } from "monaco-editor"; +import { debounceTime } from "rxjs/operators"; + +import type { HcFile } from "../../../../features/files/types"; +import { ReplacePattern } from "./monaco"; +import { + Replacement, + SearchMatch, + SearchQuery, + SearchResultsDictionary, +} from "./types"; +import { getTextModelRequired } from "../../../../features/monaco"; +import { isReadOnly } from "../../../../features/files/utils"; +import { yieldToBrowser } from "../../../../util/yieldToBrowser"; + +const replaceTextInModel = ( + model: editor.ITextModel, + replacements: Replacement[] +) => { + model.pushEditOperations( + [], + replacements.map(({ range, replaceTerm }) => ({ + range, + text: replaceTerm, + })), + () => [] + ); +}; + +const getNextContentsModel = editor.createModel(""); + +export const getNextContents = ( + file: HcFile, + model: editor.ITextModel, + replacements: Replacement[] +) => { + getNextContentsModel.setValue(model.getValue()); + replaceTextInModel(getNextContentsModel, replacements); + + const nextContents = getNextContentsModel.getValue(); + + getNextContentsModel.setValue(""); + + return nextContents; +}; + +export const replace = async ( + model: editor.ITextModel, + file: HcFile, + replacements: { range: Range; replaceTerm: string }[] +) => { + if (isReadOnly(file, true)) { + throw new Error("Attempted to trigger replace on a readOnly file"); + } + + replaceTextInModel(model, replacements); +}; + +export const focusAndSelect = (field: HTMLInputElement | null) => { + if (field) { + field.focus(); + field.select(); + } +}; + +/** + * This is a function that actually searches all of the monaco models and + * reports results. Because what it is doing is potentially CPU intensive, it is + * designed to yield to the browser as frequently as possible, which is why it + * is an async function. It is also cancellable with a passed signal. + * + * It is also incremental, in that it can simply overwrite the results for + * specified files, rather than searching every file (this is for the case of + * modifying a file whilst search results are visible). + */ +export const triggerSearch = async ( + query: SearchQuery, + filesToSearch: string[], + manifestId: string | null, + allFiles: Dictionary, + pattern: ReplacePattern | null, + prevResults: SearchResultsDictionary, + signal: AbortSignal +) => { + let nextResults = prevResults; + + for (const id of filesToSearch) { + if (signal.aborted) { + throw new Error("Aborted"); + } + + const file = allFiles[id]; + + if (!file) { + throw new Error(`Cannot search file which does not exist – ${id}`); + } + + if (query.replacing && isReadOnly(file, true)) { + nextResults = produce(nextResults, (draft) => { + delete draft[file.id]; + }); + } else { + const model = getTextModelRequired(file, manifestId); + + const modelMatches = model.findMatches( + query.searchTerm, + false, + query.regex, + query.caseSensitive, + null, + true + ); + + await yieldToBrowser(); + + if (signal.aborted) { + throw new Error("Aborted"); + } + + const fileMatches: SearchMatch[] = []; + + for (const { matches, range } of modelMatches) { + if (signal.aborted) { + throw new Error("Aborted"); + } + + if (matches && matches.length > 0) { + fileMatches.push({ + id: `${file.path.formatted}(${range.startLineNumber}-${range.startColumn}:${range.endLineNumber}:${range.endColumn}`, + replaceTerm: + pattern?.buildReplaceString(matches, query.preserveCase) ?? "", + beforeText: model.getValueInRange({ + startLineNumber: range.startLineNumber, + startColumn: model.getLineMinColumn(range.startLineNumber), + endLineNumber: range.endLineNumber, + endColumn: range.startColumn, + }), + matchedText: model.getValueInRange(range), + afterText: model.getValueInRange({ + startLineNumber: range.endLineNumber, + startColumn: range.endColumn, + endLineNumber: range.endLineNumber, + endColumn: model.getLineMaxColumn(range.endLineNumber), + }), + range, + }); + + await yieldToBrowser(); + } + } + + nextResults = produce(nextResults, (draft) => { + draft[id] = { + file, + model, + matches: fileMatches, + replacing: query.replacing, + }; + }); + } + + await yieldToBrowser(); + } + + if (signal.aborted) { + throw new Error("Aborted"); + } + + return nextResults; +}; + +export const searchDebounce = () => debounceTime(500); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/index.ts new file mode 100644 index 0000000..fb995e8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/index.ts @@ -0,0 +1,3 @@ +export { useModalNameBehavior } from "./useModalNameBehavior"; +export { useNameNewBehaviorModal } from "./useNameNewBehaviorModal"; +export { useRenameBehaviorModal } from "./useRenameBehaviorModal"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useModalNameBehavior.tsx b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useModalNameBehavior.tsx new file mode 100644 index 0000000..439404f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useModalNameBehavior.tsx @@ -0,0 +1,84 @@ +import React, { useRef } from "react"; +import { useDispatch } from "react-redux"; +import { useModal } from "react-modal-hook"; + +import { ModalNameBehavior } from "../../../Modal/NameBehavior"; +import type { ParsedPath } from "../../../../util/files/types"; +import { parse } from "../../../../util/files"; +import { trackEvent } from "../../../../features/analytics"; +import { useName } from "./useName"; + +export const useModalNameBehavior = ( + { + action, + placeholder, + onSubmit, + }: { + action: string; + placeholder: string; + onSubmit: (path: ParsedPath) => void; + }, + path?: ParsedPath, + id?: string +) => { + const dispatch = useDispatch(); + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + + const [ + { name, selectedLanguage }, + { setName, setSelectedLanguage }, + languageOptions, + [errorMessage, validate], + reset, + ] = useName(id, path); + + const [showModal, hideModal] = useModal(() => { + const done = () => { + reset(); + hideModal(); + }; + + return ( + { + evt.preventDefault(); + + if (!validate()) { + return; + } + + const path = parse({ name, ext: selectedLanguage.value }); + onSubmitRef.current(path); + dispatch( + trackEvent({ action: "New behavior", label: path.formatted }) + ); + + done(); + }} + onCancel={done} + selectedLanguage={selectedLanguage} + onSelectedLanguageChange={setSelectedLanguage} + name={name} + onNameChange={setName} + errorMessage={errorMessage} + languageOptions={languageOptions} + action={action} + placeholder={placeholder} + /> + ); + }, [ + action, + errorMessage, + languageOptions, + name, + placeholder, + selectedLanguage, + setName, + setSelectedLanguage, + validate, + reset, + ]); + + return showModal; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useName.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useName.ts new file mode 100644 index 0000000..954dd2f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useName.ts @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useSelector } from "react-redux"; +import produce, { Draft } from "immer"; + +import { Ext } from "../../../../util/files/enums"; +import type { HcFile } from "../../../../features/files/types"; +import { HcFileKind } from "../../../../features/files/enums"; +import type { ParsedPath } from "../../../../util/files/types"; +import type { ReactSelectOption } from "../../../Dropdown/types"; +import { destinationPathInUse, parse } from "../../../../util/files"; +import { selectIdKindAndPathFromFiles } from "../../../../features/files/selectors"; +import { validateFileName } from "../../../../util/validation"; + +const extensionMap = { + ".js": Ext.Js, + ".py": Ext.Py, +} as const; + +type allowedExtensionString = keyof typeof extensionMap; +type allowedExtensionValue = typeof extensionMap[allowedExtensionString]; +type LanguageOption = ReactSelectOption & { + value: allowedExtensionValue; + label: allowedExtensionString; +}; + +const isAllowedExtensionString = (str: string): str is allowedExtensionString => + extensionMap.hasOwnProperty(str); + +const isReactOptionAllowable = ( + option: ReactSelectOption +): option is LanguageOption => languageOptions.includes(option as any); + +const languageOptions: LanguageOption[] = Object.entries(extensionMap).map( + ([label, value]) => ({ + value, + label: label as allowedExtensionString, + }) +); + +const languageOptionByValue = languageOptions.reduce((acc, option) => { + acc[option.value] = option; + + return acc; +}, {}) as Record; + +const getLanguageForLanguageStr = ( + str: string | undefined +): LanguageOption | void => { + const lowerCase = str?.toLowerCase(); + + if (!(lowerCase && isAllowedExtensionString(lowerCase))) { + return; + } + + return languageOptionByValue[lowerCase]; +}; + +type NameReducerState = { name: string; selectedLanguage: LanguageOption }; +type SetName = (name: NameReducerState["name"]) => void; +type SetSelectedLanguage = (language: ReactSelectOption) => void; + +const nameReducer = produce( + ( + state: Draft, + action: + | { type: "setName"; name: string } + | { type: "setLanguage"; language: LanguageOption } + | { type: "set"; value: NameReducerState } + ): NameReducerState | void => { + switch (action.type) { + case "setLanguage": + state.selectedLanguage = action.language; + break; + + case "setName": + const matches = action.name.trim().match(/^(.*?)(\..*)?$/); + const selectedLanguage = getLanguageForLanguageStr(matches?.[2]); + + if (matches && selectedLanguage) { + state.name = matches[1]; + state.selectedLanguage = selectedLanguage; + } else { + state.name = action.name; + } + break; + + case "set": + return action.value; + } + } +); + +const validateReservedName = ( + files: Pick[], + name: string +) => { + const reservedNames = files + .filter( + (file) => + file.kind === HcFileKind.Required || file.kind === HcFileKind.Init + ) + .map((file) => file.path.name.toLowerCase()); + + return reservedNames.includes(name.toLowerCase()) + ? `"${name.toUpperCase()}" IS A RESERVED NAME` + : null; +}; + +const validateAlreadyInUse = ( + files: Parameters[0], + sourceId: string | undefined, + destination: ParsedPath +) => + destinationPathInUse(files, sourceId, destination) + ? `NAME "${destination.formatted}" ALREADY IN USE` + : null; + +type ValidateHook = [string | null, () => boolean]; + +const useValidate = (args: { + id?: string; + value: NameReducerState; +}): ValidateHook => { + const files = useSelector(selectIdKindAndPathFromFiles); + const [errorMessage, setErrorMessage] = useState(null); + + const argsRef = useRef(args); + argsRef.current = args; + + const validate = useCallback(() => { + const { value, id } = argsRef.current; + const { name, selectedLanguage } = value; + + const error = + validateFileName(name) || + validateReservedName(files, name) || + validateAlreadyInUse( + files, + id, + parse({ name, ext: selectedLanguage.value }) + ) || + null; + + setErrorMessage(error); + + return error === null; + }, [files]); + + useEffect(() => { + setErrorMessage(null); + }, [args.value]); + + return [errorMessage, validate]; +}; + +export const useName = ( + id?: string, + path?: ParsedPath +): [ + NameReducerState, + { + setName: SetName; + setSelectedLanguage: SetSelectedLanguage; + }, + ReactSelectOption[], + ValidateHook, + VoidFunction +] => { + const defaultValue = path?.name ?? ""; + const defaultLanguage = + (path?.ext && languageOptions.find((lang) => lang.value === path?.ext)) ?? + languageOptions[0]; + + const [currentValue, nameDispatch] = useReducer(nameReducer, { + name: defaultValue, + selectedLanguage: defaultLanguage, + }); + + const [errorMessage, validate] = useValidate({ id, value: currentValue }); + + const setName = useCallback( + (name) => nameDispatch({ type: "setName", name }), + [nameDispatch] + ); + + const setSelectedLanguage = useCallback( + (option) => { + if (isReactOptionAllowable(option)) { + nameDispatch({ type: "setLanguage", language: option }); + } + }, + [nameDispatch] + ); + + const defaultsRef = useRef({ defaultValue, defaultLanguage }); + useEffect(() => { + defaultsRef.current = { defaultValue, defaultLanguage }; + }); + + const reset = useCallback(() => { + nameDispatch({ + type: "set", + value: { + name: defaultsRef.current.defaultValue, + selectedLanguage: defaultsRef.current.defaultLanguage, + }, + }); + }, []); + + useEffect(() => { + setName(defaultValue); + }, [setName, defaultValue]); + + useEffect(() => { + setSelectedLanguage(defaultLanguage); + }, [setSelectedLanguage, defaultLanguage]); + + useEffect(() => { + if (id || path) { + validate(); + } + }, [id, path, validate]); + + return [ + currentValue, + { setName, setSelectedLanguage }, + languageOptions, + [errorMessage, validate], + reset, + ]; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useNameNewBehaviorModal.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useNameNewBehaviorModal.ts new file mode 100644 index 0000000..041a16a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useNameNewBehaviorModal.ts @@ -0,0 +1,24 @@ +import { useDispatch, useStore } from "react-redux"; + +import { createBehavior } from "../../../../features/files/slice"; +import { selectCurrentProject } from "../../../../features/project/selectors"; +import { useModalNameBehavior } from "./useModalNameBehavior"; + +export const useNameNewBehaviorModal = () => { + const dispatch = useDispatch(); + const store = useStore(); + + return useModalNameBehavior({ + action: "Create", + placeholder: "Name your new file", + onSubmit(path) { + const project = selectCurrentProject(store.getState()); + + if (!project) { + throw new Error("Cannot create behavior without a project"); + } + + dispatch(createBehavior({ path, project })); + }, + }); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useRenameBehaviorModal.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useRenameBehaviorModal.ts new file mode 100644 index 0000000..c3a2250 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/hooks/useRenameBehaviorModal.ts @@ -0,0 +1,26 @@ +import { useDispatch } from "react-redux"; + +import type { ParsedPath } from "../../../../util/files/types"; +import { renameBehavior } from "../../../../features/files/slice"; +import { useModalNameBehavior } from "./useModalNameBehavior"; + +export const useRenameBehaviorModal = (id: string, source: ParsedPath) => { + const dispatch = useDispatch(); + + return useModalNameBehavior( + { + action: "Rename", + placeholder: "Rename your file", + onSubmit(path) { + dispatch( + renameBehavior({ + id, + newName: path.base, + }) + ); + }, + }, + source, + id + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Files/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Files/index.ts new file mode 100644 index 0000000..ee02dc3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Files/index.ts @@ -0,0 +1,8 @@ +export { HashCoreFiles } from "./HashCoreFiles"; +export { + useModalNameBehavior, + useRenameBehaviorModal, + useNameNewBehaviorModal, +} from "./hooks"; +export { HashCoreFilesListItemFile, getDomIdByFileId } from "./ListItemFile"; +export { HashCoreFilesListItemFolder } from "./ListItemFolder"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/HashCore.tsx b/apps/sim-core/packages/core/src/components/HashCore/HashCore.tsx new file mode 100644 index 0000000..be9fe9e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/HashCore.tsx @@ -0,0 +1,155 @@ +import React, { FC, memo, useEffect, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { navigate } from "hookrouter"; + +import { DiscordWidget } from "../DiscordWidget"; +import { HashCoreAccessGate } from "./AccessGate/HashCoreAccessGate"; +import { HashCoreHeader, HashCoreMain } from "."; +import { HashCoreTour } from "./Tour"; +import { SimulationProjectWithHcFiles } from "../../features/project/types"; +import { ToastManager } from "../Toast"; +import { + description as defaultMetaDescription, + image as defaultMetaImage, +} from "../../metaTags.json"; +import { localStorageProjectKey } from "../../util/localStorageProjectKey"; +import { + selectAccessGate, + selectCurrentProject, +} from "../../features/project/selectors"; +import { selectDidSave, selectFileIds } from "../../features/files/selectors"; +import { setProjectWithMeta } from "../../features/actions"; +import { + toggleActivity, + toggleEditor, + toggleViewer, +} from "../../features/viewer/slice"; +import { trackEvent } from "../../features/analytics"; +import { urlFromProject } from "../../routes"; +import { useKeyboardShortcuts } from "../../hooks/useKeyboardShortcuts"; +import { useParameterisedUi } from "../../hooks/useParameterisedUi"; +import { useSaveOrFork } from "../../hooks/useSaveOrFork"; +import { useShouldUnload } from "../../hooks/shouldUnload"; + +export const HashCore: FC = memo(function HashCore() { + const dispatch = useDispatch(); + + const project = useSelector(selectCurrentProject); + const fileIds = useSelector(selectFileIds); + const accessGate = useSelector(selectAccessGate); + const didSave = useSelector(selectDidSave); + + useParameterisedUi(); + + const firstLoadTracked = useRef(false); + useEffect(() => { + if (project && !firstLoadTracked.current) { + dispatch( + trackEvent({ + action: "Open Project", + label: `${project.type} - ${project.pathWithNamespace} - ${project.ref} - From direct link`, + context: { + type: project.type, + }, + }) + ); + firstLoadTracked.current = true; + } + }, [dispatch, project]); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if ( + /** + * `document.hasFocus() ||` is a Safari workaround, storage events fire + * even in the same tab + * + * @see: https://bugs.webkit.org/show_bug.cgi?id=210512 + * @see: https://github.com/hashintel/internal/pull/1549 + */ + document.hasFocus() || + !( + project && + /** + * This is not sufficient because a project's local storage key can + * be changed by user interaction (renaming a project, changing + * ownership, etc). + * + * @todo Handle this edge case + */ + event.key === localStorageProjectKey(project) && + event.newValue + ) + ) { + return; + } + + const nextProject: SimulationProjectWithHcFiles = JSON.parse( + event.newValue + ); + + dispatch(setProjectWithMeta(nextProject, { replaceTabs: false })); + navigate(urlFromProject(nextProject), true, {}, false); + }; + + window.addEventListener("storage", onStorage, { passive: true }); + return () => { + window.removeEventListener("storage", onStorage); + }; + }, [dispatch, project, fileIds]); + + useShouldUnload(didSave); + + const [saveOrFork] = useSaveOrFork(); + + useKeyboardShortcuts({ + meta: { + async s() { + await saveOrFork(); + }, + }, + metaShift: { + a() { + dispatch(toggleActivity()); + }, + e() { + dispatch(toggleEditor()); + }, + y() { + dispatch(toggleViewer()); + }, + }, + }); + + useEffect(() => { + document.title = `HASH Core ${project ? ` - ${project.name}` : ""}`; + }, [project, project?.name]); + + useEffect(() => { + document + .querySelector('meta[name="description"]') + ?.setAttribute("content", project?.description || defaultMetaDescription); + document + .querySelector('meta[name="twitter:description"]') + ?.setAttribute("content", project?.description || defaultMetaDescription); + document + .querySelector('meta[name="twitter:image"]') + ?.setAttribute( + "content", + project?.image || project?.thumbnail || defaultMetaImage + ); + }, [project?.description, project?.image, project?.thumbnail]); + + return ( + + + + {accessGate ? ( + + ) : project ? ( + + ) : null} + + + ); +}); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.css b/apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.css new file mode 100644 index 0000000..bae1de0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.css @@ -0,0 +1,156 @@ +.HashCoreHeader { + --header-item-height: 24px; + --header-item-spacing: 4px; + --header-item-radius: 2px; + + background: var(--theme-darkest); + border-bottom: 1px solid var(--theme-border); + + height: 32px; + padding: 0 0 0 1.25rem; + + display: flex; + + flex: none; +} + +.HashCoreHeader__section { + line-height: 0; + display: flex; + flex: 1 1 0; + width: 0; + align-items: center; + min-width: fit-content; +} + +.HashCoreHeader__section--left { + min-width: 502px; +} + +.HashCoreHeader__section--middle { + justify-content: center; +} + +.HashCoreHeader__section--right { + justify-content: flex-end; +} + +.HashCoreHeader-logo { + margin: 0.5rem 0; +} + +.HashCoreHeader-title, +.HashCoreHeader-title::first-line { + font-size: 13px; + padding: 4px 3px 3px 6px; + margin-right: -2px; + line-height: 15px; + font-weight: bold; + font-style: normal; +} + +.HashCoreHeader-title-link { + text-decoration: none; +} + +.HashCoreHeader-title { + display: inline-flex; + fill: currentColor; + align-items: center; + user-select: none; +} + +.HashCoreHeader-title .IconBrain { + margin-right: 2px; + fill: var(--theme-blue); + position: relative; + top: -1px; +} + +.HashCoreHeader-title .IconLock { + position: relative; + margin-left: 2px; +} + +input:not(:disabled).HashCoreHeader-title:not(.submitted):hover, +input:not(:disabled).HashCoreHeader-title:active, +input:not(:disabled).HashCoreHeader-title:focus { + background-color: var(--selected-background-color); +} + +.HashCoreHeader-timeago { + display: inline-block; + font-style: normal; + color: #707070; + margin-right: var(--header-item-spacing); +} + +.HashCoreHeader__RightButton { + font-weight: bold; + background: var(--theme-dark); + transition: 0.2s ease background, 0.2s ease opacity; + border-radius: var(--header-item-radius); + + border: 0; + display: inline-block; + text-decoration: none; + margin: 0 var(--header-item-spacing); + + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.HashCoreHeader__RightButton:disabled { + opacity: 0.8; + cursor: default; +} + +.HashCoreHeader__RightButton:not(:disabled):hover { + background: var(--theme-light-on-dark); +} + +.HashCoreHeader__RightButton--CTA { + background-color: var(--theme-blue); + box-shadow: 0 0 0 0 rgba(30, 119, 255, 1); +} + +.HashCoreHeader__RightButton--CTA:not(:hover) { + animation: HashCoreHeader__RightButton--CTA-pulsate 2s infinite; +} + +@keyframes HashCoreHeader__RightButton--CTA-pulsate { + 0% { + box-shadow: 0 0 0 0 rgba(30, 119, 255, 0.7); + } + + 70% { + box-shadow: 0 0 0 10px rgba(30, 119, 255, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(30, 119, 255, 0); + } +} + +.HashCoreHeader__RightButton--CTA:hover { + background-color: var(--theme-blue-hover); +} + +.HashCoreHeader-timeago, +.HashCoreHeader__RightButton { + --font-size: 12px; + --padding: calc((var(--header-item-height) - var(--font-size)) / 2); + padding-top: var(--padding); + padding-bottom: var(--padding); + font-size: var(--font-size); + line-height: 1; +} + +@media screen and (max-width: 1300px) { + /** + * TODO: @mysterycommand - probably need to collapse the menu somehow as well + */ + .HashCoreHeader-timeago { + display: none; + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.tsx new file mode 100644 index 0000000..6a1e92a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/HashCoreHeader.tsx @@ -0,0 +1,216 @@ +import React, { FC, lazy, Suspense, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import TimeAgo from "react-timeago"; +import { useModal } from "react-modal-hook"; +import urljoin from "url-join"; + +import { HashCoreHeaderMenu } from ".."; +import { IS_STAGING } from "../../../util/api"; +import { IconBrain } from "../../Icon/Brain"; +import { IconLock } from "../../Icon/Lock"; +import { Logo } from "../../Logo"; +import { ModalPrivateDependencies } from "../../Modal/PrivateDependencies"; +import { ModalReleaseCreate, ModalReleaseUpdate } from "../../Modal"; +import type { ReleaseMeta } from "../../../util/api/types"; +import { Scope, useScopes } from "../../../features/scopes"; +import { coreVersions } from "../../../util/api/queries"; +import { projectIsPrivate } from "../../../features/project/utils"; +import { selectCurrentProject } from "../../../features/project/selectors"; +import { + selectDidSave, + selectProjectHasPrivateDependencies, +} from "../../../features/files/selectors"; + +import "./HashCoreHeader.css"; + +const shouldShowVersionPicker = IS_STAGING; + +const HashVersionPicker = shouldShowVersionPicker + ? lazy(() => + import( + /* webpackChunkName: "HashVersionPicker" */ "./HashVersionPicker" + ).then((module) => ({ + default: module.HashVersionPicker, + })) + ) + : null; + +export const HashCoreHeader: FC = () => { + const project = useSelector(selectCurrentProject); + const [versions, setVersions] = useState([]); + const isSaved = useSelector(selectDidSave); + + useEffect(() => { + const controller = new AbortController(); + if (shouldShowVersionPicker) { + coreVersions(undefined, controller.signal).then((vs) => + setVersions(vs.coreVersions) + ); + } + return controller.abort.bind(controller); + }, []); + + const [data, _setData] = useState(); + const [_showCreateReleaseModal, hideCreateReleaseModal] = useModal( + () => , + [data] + ); + + const [_showUpdateInIndex, hideUpdateInIndex] = useModal( + () => , + [] + ); + + const [_showPrivateDependencies, hidePrivateDependencies] = useModal(() => ( + + )); + + const _hasPrivateDependencies = useSelector( + selectProjectHasPrivateDependencies + ); + + const projectUpdatedAtDate = project + ? new Date(project.updatedAt) + : undefined; + + const timeagoDate = + !projectUpdatedAtDate || + projectUpdatedAtDate.getTime() > new Date().getTime() + ? new Date() + : projectUpdatedAtDate; + + const isBehaviorProject = project?.type === "Behavior"; + + const { + canLogin: _canLogin, + canRelease: _canRelease, + canSave, + canUseAccount: _canUseAccount, + canLinkToProjectInIndex, + } = useScopes( + Scope.login, + Scope.release, + Scope.save, + Scope.useAccount, + Scope.linkToProjectInIndex + ); + + /** + * These svg icons have fractional sizes to ensure they don't have + * fractional path heights which would cause them to jump around when + * toasts appear/exit + */ + const title = project ? ( + + {isBehaviorProject ? : null} + {project.name} + {!isSaved ? "*" : null} + {projectIsPrivate(project) ? : null} + + ) : null; + + return ( +
    +
    +
    + + +
    +
    +
    + {project && canLinkToProjectInIndex ? ( + + {title} + + ) : ( + title + )} + {project?.updatedAt && ( + +  - last{" "} + { + /** + * We show updated instead of saved if a user's updates are not + * going to be automatically saved + */ + canSave ? "saved" : "updated" + }{" "} + + + )} +
    +
    + {!!versions?.length && HashVersionPicker ? ( + + + + ) : null} +
    + { + /** + * We only show last published if you are on main and are able to + * edit / publish + */ + // project?.latestRelease && canRelease ? ( + // + // Last released + // + // ) : null + } + {/* {project ? : null} */} + {/* {project && canRelease ? ( + + ) : null} */} + {/* {canLogin ? ( + + Sign up / Sign in + + ) : null} */} +
    + + {/* {canUseAccount ? : null} */} +
    +
    + ); +}; + +// // @ts-ignore +// HashCoreHeader.whyDidYouRender = { +// customName: "HashCoreHeader" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.css b/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.css new file mode 100644 index 0000000..ae6af58 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.css @@ -0,0 +1,6 @@ +.HashVersionPicker { + display: flex; + flex-direction: row; + align-items: center; + margin-right: 20px; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.tsx new file mode 100644 index 0000000..6a023bc --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/HashVersionPicker.tsx @@ -0,0 +1,173 @@ +import React, { FC, useCallback, useState } from "react"; +import { Autocomplete } from "@material-ui/lab"; +import { + Button, + Popover, + TextField, + ThemeProvider, + Typography, + makeStyles, + withStyles, +} from "@material-ui/core"; + +import { getUrlForCurrentRouteWithBuildStamp } from "../../../../routes"; +import { muiTheme } from "../../../../util/material"; +import { promoteToLive } from "../../../../util/api/queries"; + +import "./HashVersionPicker.css"; + +type HashVersionPickerProps = { + versions: string[]; //TODO: @ulyssesp create a type for versions +}; + +const useStyles = makeStyles((theme) => ({ + typography: { + padding: theme.spacing(2), + }, + buttons: { + display: "flex", + flexDirection: "row", + justifyContent: "flex-end", + }, + button: { + marginRight: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})); + +const fontSize13 = { fontSize: 13 }; +const widthMaxContent = { width: "max-content !important" }; + +const PromoteButton = withStyles(() => ({ + root: { + backgroundColor: "var(--theme-dark)", + borderRadius: 0, + color: "var(--theme-grey)", + fontWeight: "bold", + textTransform: "none", + "&:hover": { + backgroundColor: "var(--theme-red)", + }, + paddingTop: 6, + }, + label: fontSize13, +}))(Button); + +const HashVersionAutocomplete = withStyles(() => ({ + root: { paddingTop: 1 }, + input: { ...fontSize13, ...widthMaxContent }, + popper: { ...fontSize13, ...widthMaxContent }, + paper: fontSize13, +}))(Autocomplete); + +export const HashVersionPicker: FC = ({ versions }) => { + const classes = useStyles(); + const [anchorEl, setAnchorEl] = useState(); + const open = Boolean(anchorEl); + const [confirmationIndex, setConfirmationIndex] = useState< + number | undefined + >(0); + + const confirmationDialogs = [ + `Promote ${WEBPACK_BUILD_STAMP} to production?`, + "You've tested everything?", + "Ok...", + ]; + + const confirmationButtons = ["Promote", "Yes", "Let's do this!"]; + + const promoteToLiveCb = useCallback(() => { + if ( + confirmationIndex !== undefined && + confirmationIndex + 1 < confirmationDialogs.length + ) { + setConfirmationIndex(confirmationIndex + 1); + } else { + const controller = new AbortController(); + promoteToLive({ stamp: WEBPACK_BUILD_STAMP }, controller.signal) + .then(() => setConfirmationIndex(undefined)) + .catch((err) => { + console.error(err); + setConfirmationIndex(0); + }); + return controller.abort.bind(controller); + } + }, [confirmationIndex, confirmationDialogs.length]); + + return ( + +
    + `${version}`} + value={WEBPACK_BUILD_STAMP} + onChange={(_, newValue) => { + if (newValue) { + // Add "hash-prod-" so it doesn't have to come down in the version list + window.location.replace( + getUrlForCurrentRouteWithBuildStamp(`hash-prod-${newValue}`) + ); + } + }} + renderInput={(params) => ( + + )} + disableClearable={true} + /> + setAnchorEl(evt.currentTarget)} + > + Promote to Prod + + setAnchorEl(undefined)} + > + {confirmationIndex === undefined ? ( +
    Good stuff.
    + ) : ( + <> + + {confirmationDialogs[confirmationIndex]} + +
    + + +
    + + )} +
    +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/index.ts new file mode 100644 index 0000000..39f0e91 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/HashVersionPicker/index.ts @@ -0,0 +1 @@ +export { HashVersionPicker } from "./HashVersionPicker"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.css b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.css new file mode 100644 index 0000000..0dc4913 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.css @@ -0,0 +1,54 @@ +.HashCoreHeaderMenuCloudStatus { + display: flex; + align-items: center; + font-weight: bold; + text-transform: uppercase; + background: var(--theme-dark-hover); + border: solid #232427; + border-width: 0 1px; + height: 32px; + padding: 0 18px; + font-size: 11px; + position: relative; + top: -1px; + margin-left: 5px; + user-select: none; +} + +.HashCoreHeaderMenuCloudStatus:disabled { + cursor: default; +} + +.HashCoreHeaderMenuCloudStatus:not(:disabled):hover { + background: var(--theme-dark-hover-hover); + border-color: var(--theme-border); +} + +.HashCoreHeaderMenuCloudStatus__Label, +.HashCoreHeaderMenuCloudStatus__Label > span { + overflow: hidden; + text-overflow: ellipsis; +} + +.HashCoreHeaderMenuCloudStatus__Indicator { + width: 5px; + height: 5px; + border-radius: 6px; + background: transparent; + margin-left: 9px; + flex-shrink: 0; + border: 1px solid blue; +} + +.HashCoreHeaderMenuCloudStatus__Indicator--running-web { + border-color: var(--theme-dark-grey); +} + +.HashCoreHeaderMenuCloudStatus__Indicator--running-cloud { + background-color: var(--theme-green-alt); + border-color: var(--theme-green-alt); +} + +.HashCoreHeaderMenuCloudStatus .SimpleTooltip { + z-index: -1; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.tsx new file mode 100644 index 0000000..116460b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/CloudStatus/HashCoreHeaderMenuCloudStatus.tsx @@ -0,0 +1,62 @@ +import React, { FC } from "react"; +import classNames from "classnames"; + +import { Scope, useScopes } from "../../../../../features/scopes"; +import { SimpleTooltip } from "../../../../SimpleTooltip"; +// import { forceLogIn } from "../../../../../features/user/utils"; +import { selectProviderTarget } from "../../../../../features/simulator/simulate/selectors"; +// import { toggleProviderTarget } from "../../../../../features/simulator/simulate/thunks"; +import { useSimulatorSelector } from "../../../../../features/simulator/context"; + +import "./HashCoreHeaderMenuCloudStatus.css"; + +export const HashCoreHeaderMenuCloudStatus: FC<{ className?: string }> = ({ + className, +}) => { + const target = useSimulatorSelector(selectProviderTarget); + // const dispatch = useSimulatorDispatch(); + const { canUseCloud, canLogin } = useScopes(Scope.useCloud, Scope.login); + const reallyDisabled = !canUseCloud && !canLogin; + + return ( + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/HashCoreHeaderMenuExperiments.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/HashCoreHeaderMenuExperiments.tsx new file mode 100644 index 0000000..7e5dcdc --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/HashCoreHeaderMenuExperiments.tsx @@ -0,0 +1,121 @@ +import React, { FC, Fragment, memo, MouseEvent } from "react"; +import { useSelector } from "react-redux"; +import { useModal } from "react-modal-hook"; + +import { DisabledExperimentTooltip } from "../../../../SimulationRunner/Controls/Experiments/ExperimentsList"; +import { ExperimentModal } from "../../../../Modal/Experiments/ExperimentModal"; +import { ExperimentTypes } from "../../../../Modal/Experiments/types"; +import { LabeledInputRadio } from "../../../../LabeledInputRadio"; +import { Scope, useScope } from "../../../../../features/scopes"; +import { queueExperiment } from "../../../../../features/simulator/simulate/queueExperiment"; +import { selectExperiments } from "../../../../SimulationRunner/Controls/Experiments/selectors"; +import { selectProviderTarget } from "../../../../../features/simulator/simulate/selectors"; +import { trackEvent } from "../../../../../features/analytics"; +import { + useSimulatorDispatch, + useSimulatorSelector, +} from "../../../../../features/simulator/context"; + +type HashCoreHeaderMenuExperimentsProps = { + openMenuItem: string; + onClickMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterMenuItemLabel: ({ target }: MouseEvent) => void; + clearAll: () => void; +}; + +export const HashCoreHeaderMenuExperiments: FC = memo( + ({ + openMenuItem, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + clearAll, + }) => { + const dispatch = useSimulatorDispatch(); + const canEdit = useScope(Scope.edit); + const experiments = useSelector(selectExperiments); + const target = useSimulatorSelector(selectProviderTarget); + const [ + openCreateExperimentModal, + hideCreateExperimentModal, + ] = useModal(() => ); + + const items = + experiments?.map( + ( + experiment: [string, { description: string; type: ExperimentTypes }] + ) => { + const experimentTitle = experiment[0]; + + const disabledOptimizationExperiment = + target !== "cloud" && experiment[1].type === "optimization"; + if (disabledOptimizationExperiment) { + return ( +
  • + {experimentTitle} + +
  • + ); + } + + return ( +
  • + { + clearAll(); + dispatch(queueExperiment(experimentTitle)); + }} + > + {experimentTitle} + +
  • + ); + } + ) ?? []; + + if (canEdit) { + items.push( + + {items.length ? ( +
  • +
    +
  • + ) : null} +
  • + { + clearAll(); + openCreateExperimentModal(); + trackEvent({ + action: "Experiment wizard opened", + label: "Menu", + }); + }} + > + Create new experiment + +
  • +
    + ); + } + + return ( + <> + htmlFor === openMenuItem} + onClick={onClickMenuItemLabel} + onMouseEnter={onMouseEnterMenuItemLabel} + disabled={items.length === 0} + /> +
      {items}
    + + ); + } +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/index.ts new file mode 100644 index 0000000..3a372be --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Experiments/index.ts @@ -0,0 +1 @@ +export { HashCoreHeaderMenuExperiments } from "./HashCoreHeaderMenuExperiments"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.spec.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.spec.tsx new file mode 100644 index 0000000..8cb0388 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.spec.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { ModalProvider } from "react-modal-hook"; + +import { HashCoreHeaderMenuFiles } from "./HashCoreHeaderMenuFiles"; +import { store } from "../../../../../features/store"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( + + + {}} + onClickMenuItemLabel={() => {}} + onMouseEnterMenuItemLabel={() => {}} + onMouseEnterSubmenuItemLabel={() => {}} + onMouseEnterSubmenuItem={() => {}} + onMouseLeaveSubmenuItem={() => {}} + userProjects={[]} + exampleProjects={[]} + /> + + , + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.tsx new file mode 100644 index 0000000..57833b9 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/HashCoreHeaderMenuFiles.tsx @@ -0,0 +1,348 @@ +import React, { ChangeEvent, FC, memo, MouseEvent, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useModal } from "react-modal-hook"; + +import { IconBrain } from "../../../../Icon/Brain"; +import { IconLock } from "../../../../Icon/Lock"; +import { LabeledInputRadio } from "../../../../LabeledInputRadio"; +import { Link } from "../../../../Link/Link"; +import { ModalNewDataset } from "../../../../Modal/NewDataset/ModalNewDataset"; +import { PartialSimulationProject } from "../../../../../features/project/types"; +import { Scope } from "../../../../../features/scopes"; +import { descByUpdatedAt } from "../../../../../util/descByUpdatedAt"; +import { mainProjectPath, urlFromProject } from "../../../../../routes"; +import { selectCurrentProject } from "../../../../../features/project/selectors"; +import { selectUserProfileUrl } from "../../../../../features/user/selectors"; +import { trackEvent } from "../../../../../features/analytics"; +import { + useExportFiles, + useImportFiles, +} from "../../../../../features/files/hooks"; +import { useNameNewBehaviorModal } from "../../../Files/hooks"; +import { useSaveOrFork } from "../../../../../hooks/useSaveOrFork"; + +type HashCoreHeaderMenuFilesProps = { + openMenuItem: string; + openSubmenuItem: string; + clearAll: () => void; + onClickMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterSubmenuItemLabel: ({ + target, + }: MouseEvent) => void; + onMouseEnterSubmenuItem: ({ target }: MouseEvent) => void; + onMouseLeaveSubmenuItem: ({ target }: MouseEvent) => void; + userProjects: PartialSimulationProject[]; + exampleProjects: PartialSimulationProject[]; +}; + +/** + * @todo most of these props do not need to be props – define them locally instead + */ +export const HashCoreHeaderMenuFiles: FC = memo( + ({ + openMenuItem, + openSubmenuItem, + clearAll, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + onMouseEnterSubmenuItemLabel, + onMouseEnterSubmenuItem, + onMouseLeaveSubmenuItem, + userProjects, + exampleProjects: _exampleProjects, + }) => { + const userProfileUrl = useSelector(selectUserProfileUrl); + const project = useSelector(selectCurrentProject); + const dispatch = useDispatch(); + // const forkUrl = project ? forkUrlFromProject(project) : null; + + const showModalNewBehavior = useNameNewBehaviorModal(); + const [_showNewDatasetModal, hideNewDatasetModal] = useModal( + () => , + [] + ); + + const exportFiles = useExportFiles(); + const importFiles = useImportFiles(); + const importFileRef = useRef(null); + const onImportClick = () => { + importFileRef.current?.click(); + }; + + const [ + _saveOrFork, + _canSaveOrFork, + _requireLoginToSaveOrFork, + { canSave, canFork: _canFork, canForkIfSignedIn: _canForkIfSignedIn }, + ] = useSaveOrFork(); + // const canLinkToProjectInIndex = useScope(Scope.linkToProjectInIndex); + // const isFork = !!project?.forkOf; + // const mergeRequestUrl = project && isFork ? createMergeRequestUrl(project) : ""; + + const toListItem = (type: "Example" | "User") => ( + item: PartialSimulationProject + ) => { + const href = + type === "User" + ? mainProjectPath(item.pathWithNamespace) + : urlFromProject(item); + + return ( +
  • + { + dispatch( + trackEvent({ + action: "Open project", + label: `${type} - ${item.pathWithNamespace} - ${item.ref} - From menu`, + context: { + type, + }, + }) + ); + + clearAll(); + }} + className="HashCoreHeaderMenuProjectLink" + > + {item.name} + {item.visibility === "private" ? : null} + {item.type === "Behavior" ? : null} + +
  • + ); + }; + + return ( + <> + htmlFor === openMenuItem} + onClick={onClickMenuItemLabel} + onMouseEnter={onMouseEnterMenuItemLabel} + /> +
      + {project && canSave && ( +
    • + htmlFor === openSubmenuItem} + onMouseEnter={onMouseEnterSubmenuItemLabel} + /> +
        + { + showModalNewBehavior(); + clearAll(); + }} + > + New behavior + + {/* { + showNewDatasetModal(); + clearAll(); + }} + > + New dataset + */} +
      +
    • + )} + {/*
    • + htmlFor === openSubmenuItem} + onMouseEnter={onMouseEnterSubmenuItemLabel} + /> +
        + { + clearAll(); + }} + > + Empty simulation + + { + clearAll(); + }} + > + From starter template + +
      +
    • */} +
    • +
      +
    • + {userProjects.length ? ( +
    • + htmlFor === openSubmenuItem} + onMouseEnter={onMouseEnterSubmenuItemLabel} + /> +
        + {[...userProjects] + .sort(descByUpdatedAt) + .slice(0, 10) + .map(toListItem("User"))} + {userProfileUrl ? ( + <> + {userProjects.length ? ( +
      • +
        +
      • + ) : null} + {/*
      • + + My projects + +
      • */} + + ) : null} +
      +
    • + ) : null} + {/* {exampleProjects.length ? ( +
    • + htmlFor === openSubmenuItem} + onMouseEnter={onMouseEnterSubmenuItemLabel} + /> +
        + {[...exampleProjects] + .sort(descByUpdatedAt) + .map(toListItem("Example"))} +
      +
    • + ) : null} */} +
    • + ) => { + evt.preventDefault(); + clearAll(); + const files = evt.currentTarget.files; + if (files) { + importFiles(files).catch((err) => + console.error( + `Error importing project files: ${err.message}` + ) + ); + } + }} + /> + + Import project + +
    • +
    • +
      +
    • + {/*
    • + {project && canSaveOrFork ? ( + ) => { + evt.preventDefault(); + clearAll(); + await saveOrFork(); + }} + > +
      + Save project +
      + {getMetaCharacter()} + S +
      +
      + + ) : null} +
    • */} + {/* {project && forkUrl && (canFork || canForkIfSignedIn) ? ( +
    • + { + clearAll(); + }} + > + Fork project + +
    • + ) : null} */} + {project ? ( +
    • + ) => { + evt.preventDefault(); + clearAll(); + exportFiles().catch((err) => + console.error( + `Error exporting project files: ${err.message}` + ) + ); + }} + > + Export project + +
    • + ) : null} + {/*
    • +
      +
    • + {project && isFork ? ( +
    • + + Create merge request + +
    • + ) : null} */} + {/* {project && canLinkToProjectInIndex ? ( +
    • + + View project in HASH + +
    • + ) : null} */} +
    + + ); + } +); + +// // @ts-ignore +// HashCoreHeaderMenuFiles.whyDidYouRender = { +// customName: "HashCoreHeaderMenuFiles" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/index.ts new file mode 100644 index 0000000..504e8f7 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Files/index.ts @@ -0,0 +1 @@ +export { HashCoreHeaderMenuFiles } from "./HashCoreHeaderMenuFiles"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.scss b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.scss new file mode 100644 index 0000000..464a777 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.scss @@ -0,0 +1,206 @@ +:root { + --shortcut-bg: #1b1d24; + --shortcut-text-color: var(--white); +} + +.HashCoreHeaderMenu { + display: inline-block; + vertical-align: top; + margin: 0 1rem; + padding: 0; + list-style-type: none; + position: relative; + z-index: 303; +} + +.HashCoreHeaderMenu a { + text-decoration: none; +} + +.HashCoreHeaderMenu hr { + margin: 0 auto; + border: 0; + height: 1px; + background: var(--theme-border); + width: 85%; +} + +.HashCoreHeaderMenu-item, +.HashCoreHeaderMenu-submenu-item { + display: inherit; + font-weight: bold; + position: relative; +} + +/** + * TODO: @mysterycommand - figure out how to DRY this up + * @see: ../HashCoreHeader/HashCoreHeader.css ~ L23 + */ +.HashCoreHeaderMenu-submenu-item a, +.HashCoreHeaderMenu-submenu-item--disabled > span { + /* padding-vertical + (font-size * line-height) adds up to 32px */ + padding: 10px 0.5rem 9px; + font-size: 13px; + line-height: 1; + + cursor: pointer; + user-select: none; + display: block; + + background: var(--theme-darkest); + transition: background 0.1s; +} + +.HashCoreHeaderMenu-submenu-item--disabled { + > a { + cursor: default; + opacity: 0.66; + } +} + +.HashCoreHeaderMenu-submenu-item:not(.HashCoreHeaderMenu-submenu-item--disabled) + a:hover { + background: var(--theme-dark); +} + +.HashCoreHeaderMenu-submenu-item--disabled > span { + cursor: default; + background: #3a3a3a; + color: #d2d2d2; + font-weight: 400; +} + +.HashCoreHeaderMenu-item > label { + border-left: 1px solid transparent; + border-right: 1px solid transparent; + + transition: border 0.1s; +} + +.HashCoreHeaderMenu-item > input:checked + label { + border-left: 1px solid var(--theme-border); + border-right: 1px solid var(--theme-border); +} + +.HashCoreHeaderMenu-item > input + label + ul, +.HashCoreHeaderMenu-submenu-item > input + label + ul { + display: none; + position: absolute; + border: 1px solid var(--theme-border); +} + +.HashCoreHeaderMenu-submenu-item > input + label + ul { + top: 0; + left: 100%; +} + +.HashCoreHeaderMenu-item > input:checked + label + ul, +.HashCoreHeaderMenu-submenu-item > input:checked + label + ul { + display: block; + z-index: 1; +} + +.HashCoreHeaderMenu-submenu, +.HashCoreHeaderMenu-submenu-item > ul { + background: var(--theme-darkest); + list-style-type: none; + margin: 0; + padding: 0; + border-bottom-right-radius: 6px; + border-bottom-left-radius: 6px; + min-width: 160px; +} + +.HashCoreHeaderMenu-submenu-item ul { + overflow: hidden; + border-top-right-radius: 6px; +} + +.HashCoreHeaderMenu-submenu-item a, +.HashCoreHeaderMenu-submenu-item label, +.HashCoreHeaderMenu-submenu-item--disabled > span { + white-space: nowrap; + padding-right: 1rem; + padding-left: 1rem; +} + +.HashCoreHeaderMenu-submenu-item label { + padding-right: calc(1rem + 14px); +} + +.HashCoreHeaderMenu-submenu-item label::after { + content: "\25B8"; + position: absolute; + right: 14px; +} + +.HashCoreHeaderMenu-submenu-item > a[title="Coming soon"] { + color: rgba(255, 255, 255, 0.25); + cursor: not-allowed; +} + +.HashCoreHeaderMenu-submenu li:last-child, +.HashCoreHeaderMenu-submenu li:last-child > a, +.HashCoreHeaderMenu-submenu li:last-child > label { + border-bottom-right-radius: 6px; + border-bottom-left-radius: 6px; +} + +.HashCoreHeaderMenu-submenu li:last-child > a, +.HashCoreHeaderMenu-submenu li:last-child > label { + padding-bottom: 12px; +} + +.HashCoreHeaderMenu-submenu-item .HashCoreHeaderMenu__LabelWithHint { + display: flex; + justify-content: space-between; + align-items: center; + + .HashCoreHeaderMenu__LabelWithHint__Hint { + color: var(--shortcut-text-color); + margin: 5px 0 5px 0; + + span { + background-color: var(--shortcut-bg); + padding: 5px; + border-radius: 4px; + margin-left: 4px; + } + } +} + +/** + * TODO: @mysterycommand - this is super brittle/coupled, but works and allows + * the `drive` and `examples` to share a common map `toListItem` function + */ +label[for="HashCoreHeaderMenu-submenu::My simulations"] + + ul + > li:nth-last-child(3) + > a { + padding-bottom: 12px; +} + +.HashCoreHeaderMenuProjectLink { + display: flex !important; + align-items: center; + justify-content: space-between; + + span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + + .Icon { + // Ensures the icon doesn't make the list item taller + margin: -6px 0 -6px 8px; + } + + .IconBrain { + fill: var(--theme-blue); + } + + .IconLock { + fill: white; + } +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.tsx new file mode 100644 index 0000000..6f03878 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/HashCoreHeaderMenu.tsx @@ -0,0 +1,94 @@ +import React, { FC, memo, useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { AppDispatch } from "../../../../features/types"; +import { HashCoreHeaderMenuExperiments } from "./Experiments"; +import { HashCoreHeaderMenuFiles } from "./Files"; +import { HashCoreHeaderMenuHelp } from "./Help"; +import { HashCoreHeaderMenuView } from "./View"; +import { openTab } from "../../../../features/viewer/slice"; +import { selectExamples } from "../../../../features/examples/selectors"; +import { selectUserProjects } from "../../../../features/user/selectors"; +import { useMenu } from "./hooks"; + +import "./HashCoreHeaderMenu.scss"; + +/** + * @todo nathggns: Look into removing memo and useCallback in here + */ +export const HashCoreHeaderMenu: FC = memo(() => { + const dispatch = useDispatch(); + const userProjects = useSelector(selectUserProjects); + const examples = useSelector(selectExamples); + + const { + menuRef, + openMenuItem, + openSubmenuItem, + clearAll, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + onMouseEnterSubmenuItemLabel, + onMouseEnterSubmenuItem, + onMouseLeaveSubmenuItem, + } = useMenu(); + + const onAddView = useCallback( + (tab) => { + dispatch(openTab(tab)); + }, + [dispatch] + ); + + return ( +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • + {/*
    • + +
    • */} +
    + ); +}); + +// // @ts-ignore +// HashCoreHeaderMenu.whyDidYouRender = { +// customName: "HashCoreHeaderMenu" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/HashCoreHeaderMenuHelp.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/HashCoreHeaderMenuHelp.tsx new file mode 100644 index 0000000..178310d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/HashCoreHeaderMenuHelp.tsx @@ -0,0 +1,100 @@ +import React, { FC, memo, MouseEvent } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { DISCORD_URL } from "../../../../DiscordWidget/DiscordWidget"; +import { LabeledInputRadio } from "../../../../LabeledInputRadio"; +import { selectHasProject } from "../../../../../features/project/selectors"; +import { trackEvent } from "../../../../../features/analytics"; +import { useTour } from "../../../Tour"; + +type HashCoreHeaderMenuHelpProps = { + openMenuItem: string; + onClickMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterMenuItemLabel: ({ target }: MouseEvent) => void; + clearAll: () => void; +}; + +export const HashCoreHeaderMenuHelp: FC = memo( + ({ + openMenuItem, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + clearAll, + }) => { + const tour = useTour(); + // const canUseAccount = useScope(Scope.useAccount); + const hasProject = useSelector(selectHasProject); + const dispatch = useDispatch(); + + return ( + <> + htmlFor === openMenuItem} + onClick={onClickMenuItemLabel} + onMouseEnter={onMouseEnterMenuItemLabel} + /> + + + ); + } +); + +// // @ts-ignore +// HashCoreHeaderMenuHelp.whyDidYouRender = { +// customName: "HashCoreHeaderMenuHelp" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/index.ts new file mode 100644 index 0000000..72b157e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/Help/index.ts @@ -0,0 +1 @@ +export { HashCoreHeaderMenuHelp } from "./HashCoreHeaderMenuHelp"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/HashCoreHeaderMenuView.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/HashCoreHeaderMenuView.tsx new file mode 100644 index 0000000..22c7580 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/HashCoreHeaderMenuView.tsx @@ -0,0 +1,197 @@ +import React, { FC, Fragment, memo, MouseEvent } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import classNames from "classnames"; + +import { LabeledInputRadio } from "../../../../LabeledInputRadio"; +import { Link } from "../../../../Link/Link"; +import { Scope, useScopes } from "../../../../../features/scopes"; +import { TabKind } from "../../../../../features/viewer/enums"; +import { getMetaCharacter } from "../../../../../hooks/useKeyboardShortcuts"; +import { openSearch } from "../../../../../features/search/slice"; +import { + selectActivityVisible, + selectEditorVisible, + selectViewerVisible, +} from "../../../../../features/viewer/selectors"; +import { selectHasProject } from "../../../../../features/project/selectors"; +import { + toggleActivity, + toggleEditor, + toggleViewer, +} from "../../../../../features/viewer/slice"; +import { viewerTabs } from "../../../../../features/viewer/utils"; + +type HashCoreHeaderMenuViewProps = { + openMenuItem: string; + onClickMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterMenuItemLabel: ({ target }: MouseEvent) => void; + onAddView: (tabName: TabKind) => void; + clearAll: () => void; +}; + +export const HashCoreHeaderMenuView: FC = memo( + ({ + openMenuItem, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + onAddView, + clearAll, + }) => { + const dispatch = useDispatch(); + const { canEdit, canLogin } = useScopes(Scope.edit, Scope.login); + const hasProject = useSelector(selectHasProject); + const editorVisible = useSelector(selectEditorVisible); + const activityVisible = useSelector(selectActivityVisible); + const viewerVisible = useSelector(selectViewerVisible); + + const items = []; + + if (hasProject) { + items.push( + ...viewerTabs.map((tab) => ( +
  • + { + onAddView(tab.kind); + clearAll(); + }} + > + {tab.name} + +
  • + )) + ); + + if (editorVisible) { + items.push( +
  • + { + clearAll(); + dispatch(openSearch()); + }} + > + {canEdit ? <>Search & Replace : <>Search} + +
  • + ); + } + } + + items.push( + + {items.length ? ( +
  • +
    +
  • + ) : null} +
  • + { + clearAll(); + dispatch(toggleEditor()); + }} + > +
    + {editorVisible ? <>Hide Editor : <>Show Editor} +
    + {getMetaCharacter()} + Shift + E +
    +
    +
    +
  • +
  • + { + clearAll(); + dispatch(toggleViewer()); + }} + > +
    + + {activityVisible ? <>Hide Viewer : <>Show Viewer} + +
    + {getMetaCharacter()} + Shift + Y +
    +
    +
    +
  • +
  • + { + if (viewerVisible) { + clearAll(); + dispatch(toggleActivity()); + } + }} + > +
    + + {activityVisible && viewerVisible ? ( + <>Hide Activity + ) : ( + <>Show Activity + )} + +
    + {getMetaCharacter()} + Shift + A +
    +
    +
    +
  • +
    + ); + + if (canLogin) { + items.push( + + {items.length ? ( +
  • +
    +
  • + ) : null} +
  • + + Sign up + +
  • +
  • + + Sign in + +
  • +
    + ); + } + + return ( + <> + htmlFor === openMenuItem} + onClick={onClickMenuItemLabel} + onMouseEnter={onMouseEnterMenuItemLabel} + disabled={items.length === 0} + /> +
      {items}
    + + ); + } +); + +// // @ts-ignore +// HashCoreHeaderMenuView.whyDidYouRender = { +// customName: "HashCoreHeaderMenuView" +// }; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/index.ts new file mode 100644 index 0000000..7d4b5b4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/View/index.ts @@ -0,0 +1 @@ +export { HashCoreHeaderMenuView } from "./HashCoreHeaderMenuView"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/index.ts new file mode 100644 index 0000000..c34d6b5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/index.ts @@ -0,0 +1,2 @@ +export { useClickOutside } from "./useClickOutside"; +export { useMenu } from "./useMenu"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useClickOutside.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useClickOutside.ts new file mode 100644 index 0000000..1100063 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useClickOutside.ts @@ -0,0 +1,20 @@ +import { DependencyList, RefObject, useCallback, useRef } from "react"; + +import { useOnClickOutside } from "../../../../../hooks/useOnClickOutside"; + +/** + * @deprecated + * @see useOnClickOutside + */ +export function useClickOutside( + callback: () => void, + dependencyList: DependencyList = [] +): RefObject { + const ref = useRef(null); + + const memodCallback = useCallback(callback, dependencyList); + + useOnClickOutside(ref, memodCallback); + + return ref; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useMenu.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useMenu.ts new file mode 100644 index 0000000..012dbc4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/hooks/useMenu.ts @@ -0,0 +1,132 @@ +import { useState, MouseEvent, RefObject, useMemo } from "react"; +import { debounce } from "lodash"; + +import { useClickOutside } from "./useClickOutside"; + +type MenuInterface = { + menuRef: RefObject; + openMenuItem: string; + openSubmenuItem: string; + clearAll: () => void; + onClickMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterMenuItemLabel: ({ target }: MouseEvent) => void; + onMouseEnterSubmenuItemLabel: ({ + target, + }: MouseEvent) => void; + onMouseEnterSubmenuItem: ({ target }: MouseEvent) => void; + onMouseLeaveSubmenuItem: ({ target }: MouseEvent) => void; +}; + +function isHtmlLabelElement(target: EventTarget): target is HTMLLabelElement { + return target instanceof HTMLLabelElement; +} + +const noItem = ""; +const debounced = debounce((fn) => fn(), 200); +const onMouseEnterSubmenuItem = () => { + /** + * if we have entered a submenu item (i.e. an + * `li.HashCoreHeaderMenu-submenu-item`) and there's a debounced state + * update pending, cancel it + * + * this gets triggered *before* `onMouseEnterSubmenuItemLabel`, so if a user + * has legitimately moused into another submenu item (label) that state + * update still gets scheduled + * + * if a user has moused over/past a submenu item label and into a submenu + * (i.e. an `li.HashCoreHeaderMenu-submenu-item > ul`) the fact that the + * `ul` is contained by the `li.HashCoreHeaderMenu-submenu-item` will cause + * this to (re)fire and cancel the pending state update triggered by the + * intervening call to `onMouseEnterSubmenuItemLabel` + */ + debounced.cancel(); +}; + +export function useMenu(): MenuInterface { + const [openMenuItem, setOpenMenuItem] = useState(noItem); + const [openSubmenuItem, setOpenSubmenuItem] = useState(noItem); + + const { + clearAll, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + onMouseEnterSubmenuItemLabel, + onMouseLeaveSubmenuItem, + } = useMemo( + () => ({ + clearAll: () => { + setOpenMenuItem(noItem); + setOpenSubmenuItem(noItem); + }, + + onClickMenuItemLabel: ({ target }: MouseEvent) => { + if (!isHtmlLabelElement(target)) { + return; + } + + const { htmlFor } = target; + setOpenMenuItem((prev) => (prev === htmlFor ? noItem : htmlFor)); + }, + + onMouseEnterMenuItemLabel: ({ target }: MouseEvent) => { + if (!isHtmlLabelElement(target)) { + return; + } + + const { htmlFor } = target; + + debounced(() => { + /** + * n.b. compares previous `openMenuItem` to `noItem` ... if a user moused + * over this menu item and no item was previously selected we don't want + * to open a new menu on mouse enter, if however an item *was* previously + * selected we must have moved from one open menu to another and should + * update the open menu item accordingly + */ + setOpenMenuItem((prev) => (prev === noItem ? noItem : htmlFor)); + setOpenSubmenuItem(noItem); + }); + }, + + onMouseEnterSubmenuItemLabel: ({ + target, + }: MouseEvent) => { + if (!isHtmlLabelElement(target)) { + return; + } + + const { htmlFor } = target; + + debounced(() => { + setOpenSubmenuItem(htmlFor); + }); + }, + + onMouseLeaveSubmenuItem: ({ target }: MouseEvent) => { + if (!isHtmlLabelElement(target)) { + return; + } + + debounced(() => { + setOpenSubmenuItem(noItem); + }); + }, + }), + [setOpenMenuItem, setOpenSubmenuItem] + ); + + // @todo use useOnClickOutside + const menuRef = useClickOutside(clearAll); + + return { + menuRef, + openMenuItem, + openSubmenuItem, + clearAll, + onClickMenuItemLabel, + onMouseEnterMenuItemLabel, + onMouseEnterSubmenuItemLabel, + onMouseEnterSubmenuItem, + onMouseLeaveSubmenuItem, + }; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/index.ts new file mode 100644 index 0000000..95ddef0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/Menu/index.ts @@ -0,0 +1,2 @@ +export { HashCoreHeaderMenu } from "./HashCoreHeaderMenu"; +export { useClickOutside, useMenu } from "./hooks"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/ShareButton/HashCoreHeaderShareButton.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/ShareButton/HashCoreHeaderShareButton.tsx new file mode 100644 index 0000000..3fb4311 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/ShareButton/HashCoreHeaderShareButton.tsx @@ -0,0 +1,29 @@ +import React, { FC, useEffect } from "react"; +import { useModal } from "react-modal-hook"; + +import { ModalShare } from "../../../Modal/Share/ModalShare"; + +export const HashCoreHeaderShareButton: FC = () => { + const [showModal, hideModal] = useModal( + () => , + [] + ); + + useEffect(() => { + return () => { + hideModal(); + }; + }, [hideModal]); + + return ( + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.css b/apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.css new file mode 100644 index 0000000..4b9b4b9 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.css @@ -0,0 +1,16 @@ +.HashCoreHeaderUserImage { + background-color: var(--theme-grey); + width: var(--header-item-height); + height: var(--header-item-height); + overflow: hidden; + display: flex; + justify-content: center; + align-items: stretch; + border-radius: var(--header-item-radius); + margin-right: var(--header-item-spacing); +} + +.HashCoreHeaderUserImage img { + height: 100%; + width: auto; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.tsx b/apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.tsx new file mode 100644 index 0000000..0da602a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/UserImage/HashCoreHeaderUserImage.tsx @@ -0,0 +1,29 @@ +import React, { FC } from "react"; +import { useSelector } from "react-redux"; + +import { + selectUserImage, + selectUserProfileUrl, +} from "../../../../features/user/selectors"; + +import "./HashCoreHeaderUserImage.css"; + +export const HashCoreHeaderUserImage: FC = () => { + const url = useSelector(selectUserProfileUrl); + const image = useSelector(selectUserImage); + + if (!url) { + throw new Error("Cannot display user image without profile to link to"); + } + + return ( + + {image ? User profile image : null} + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Header/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Header/index.ts new file mode 100644 index 0000000..99500e8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Header/index.ts @@ -0,0 +1,2 @@ +export { HashCoreHeader } from "./HashCoreHeader"; +export { HashCoreHeaderMenu, useClickOutside, useMenu } from "./Menu"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.css b/apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.css new file mode 100644 index 0000000..2ffaa59 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.css @@ -0,0 +1,5 @@ +.HashCoreMain { + position: relative; + flex: auto; + display: flex; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.tsx b/apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.tsx new file mode 100644 index 0000000..858b883 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Main/HashCoreMain.tsx @@ -0,0 +1,47 @@ +import React, { FC } from "react"; +import { useSelector } from "react-redux"; + +import { HashCoreAside, HashCoreSection } from ".."; +import { WrappedSplitterLayout } from "../../WrappedSplitterLayout/WrappedSplitterLayout"; +import { selectEditorVisible } from "../../../features/viewer/selectors"; +import { useAddClassOnClick } from "./util"; + +import "./HashCoreMain.css"; + +const SIDEBAR_SIZE = 180; + +export const HashCoreMain: FC = () => { + // Necessary to prevent the transition delay delaying the seperator + // colour changing back on mouseup + const [setContainerRef] = useAddClassOnClick( + "layout-splitter", + "layout-splitter-no-transition-delay" + ); + + // Some floating elements need to be offseted so as not to cover the + // files panel. This creates a CSS variable to allow them to do that. + const onSecondaryPaneSizeChange = (size: number) => { + document.documentElement.style.setProperty( + "--left-pane-width", + `${size}px` + ); + }; + + const editorVisible = useSelector(selectEditorVisible); + + return ( +
    + + + + +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Main/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Main/index.ts new file mode 100644 index 0000000..71d22e2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Main/index.ts @@ -0,0 +1 @@ +export { HashCoreMain } from "./HashCoreMain"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Main/util.ts b/apps/sim-core/packages/core/src/components/HashCore/Main/util.ts new file mode 100644 index 0000000..8a4e079 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Main/util.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +export function useAddClassOnClick(className: string, classToAdd: string) { + const [container, setContainer] = useState(null); + + useEffect(() => { + let currentSplitter: HTMLElement | null; + let timeout: any; + + function mouseup() { + if (currentSplitter) { + const { classList } = currentSplitter; + + classList.add(classToAdd); + timeout = setTimeout(() => { + classList.remove(classToAdd); + }); + + currentSplitter = null; + } + } + + function mousedown(evt: MouseEvent) { + const target = evt.target as HTMLElement; + + if (target.classList.contains(className)) { + currentSplitter = target; + } + } + + container?.addEventListener("mousedown", mousedown); + document.addEventListener("mouseup", mouseup); + + return () => { + container + ?.querySelectorAll(`.${className}.${classToAdd}`) + ?.forEach((node) => node.classList.remove(classToAdd)); + + container?.removeEventListener("mousedown", mousedown); + document.removeEventListener("mouseup", mouseup); + clearTimeout(timeout); + currentSplitter = null; + }; + }, [container, classToAdd, className]); + + return [setContainer]; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.css b/apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.css new file mode 100644 index 0000000..068ee0b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.css @@ -0,0 +1,29 @@ +.HashCoreResources { + --resources-padding: 17px; + background: var(--theme-dark); + height: 100%; + + padding: var(--resources-padding) var(--resources-padding) 0; + overflow: auto; + overflow-y: hidden; + box-sizing: border-box; + + display: flex; + flex-direction: column; +} + +.HashCoreResources__h1 { + font-size: 12px; + line-height: 130%; + margin-bottom: 8px; + margin-top: 0; + padding: 0; + text-align: center; + text-transform: uppercase; + user-select: none; +} +.HashCoreResources .Search__Icon.clearable { + pointer-events: auto; + cursor: pointer; + user-select: none; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.tsx b/apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.tsx new file mode 100644 index 0000000..ba6d2ca --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/HashCoreResources.tsx @@ -0,0 +1,12 @@ +import React, { FC } from "react"; + +import { HashCoreResourcesSearchableIndex } from "."; + +import "./HashCoreResources.css"; + +export const HashCoreResources: FC = () => ( +
    +

    Add to Project

    + +
    +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.css b/apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.css new file mode 100644 index 0000000..7bed6c1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.css @@ -0,0 +1,6 @@ +.HashCoreResourcesList { + overflow-y: scroll; + margin-right: -1em; + padding-right: 1em; + padding-bottom: var(--resources-padding); +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.tsx b/apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.tsx new file mode 100644 index 0000000..321242f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/List/HashCoreResourcesList.tsx @@ -0,0 +1,22 @@ +import React, { FC } from "react"; + +import { ResourceListItem } from "../../../ResourceListItem"; +import { ResourceProject } from "../../../../features/project/types"; + +import "./HashCoreResourcesList.css"; + +export type HashCoreResourcesListProps = { + results: ResourceProject[]; +}; + +export const HashCoreResourcesList: FC = ({ + results, +}) => ( +
    + {results.map((resource, id) => + resource.files.length ? ( + + ) : null + )} +
    +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/List/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Resources/List/index.ts new file mode 100644 index 0000000..3759466 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/List/index.ts @@ -0,0 +1 @@ +export { HashCoreResourcesList } from "./HashCoreResourcesList"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/HashCoreResourcesSearchableIndex.tsx b/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/HashCoreResourcesSearchableIndex.tsx new file mode 100644 index 0000000..88cbf8f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/HashCoreResourcesSearchableIndex.tsx @@ -0,0 +1,16 @@ +import React, { FC } from "react"; + +import { HashCoreResourcesList } from ".."; +import { Search } from "../../../Search"; +import { useSearchIndex } from "./hooks"; + +export const HashCoreResourcesSearchableIndex: FC = () => { + const { onChange, loading, results, searchTerm } = useSearchIndex(); + + return ( + <> + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/hooks.ts b/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/hooks.ts new file mode 100644 index 0000000..8e63533 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/hooks.ts @@ -0,0 +1,150 @@ +import { useEffect, useReducer, useRef } from "react"; +import { useDispatch, useStore } from "react-redux"; +import { Subject, combineLatest, merge } from "rxjs"; +import { + debounceTime, + distinctUntilChanged, + filter, + map, + skip, + take, + withLatestFrom, +} from "rxjs/operators"; + +import type { ResourceProject } from "../../../../features/project/types"; +import { Scope, selectScope } from "../../../../features/scopes"; +import { fromStore } from "../../../../util/fromStore"; +import { projectChangeObservable } from "../../../../features/project/observables"; +import { searchResourceProjects } from "../../../../util/api/queries/searchResourceProjects"; +import { + selectLatestReleaseTag, + selectProjectLoaded, +} from "../../../../features/project/selectors"; +import { trackEvent } from "../../../../features/analytics"; + +export const useSearchIndex = (): { + loading: boolean; + results: ResourceProject[]; + onChange: (term: string) => void; + searchTerm: string; +} => { + const searchTermSubjectRef = useRef(new Subject()); + const appDispatch = useDispatch(); + const store = useStore(); + + const [{ loading, results, searchTerm }, dispatch] = useReducer( + ( + state: { + loading: boolean; + results: ResourceProject[]; + searchTerm: string; + }, + action: + | { type: "SEARCH"; payload: string } + | { type: "BEGIN_SEARCH" } + | { type: "FINISHED_SEARCHING"; payload: ResourceProject[] } + | { type: "ERROR" } + ) => { + switch (action.type) { + case "SEARCH": + return { ...state, loading: true, searchTerm: action.payload }; + + case "BEGIN_SEARCH": + return { ...state, loading: true }; + + case "ERROR": + return { ...state, loading: false, results: [] }; + + case "FINISHED_SEARCHING": + return { ...state, loading: false, results: action.payload }; + } + }, + { loading: true, results: [], searchTerm: "" } + ); + + useEffect(() => { + const search = async (searchTerm: string, signal: AbortSignal) => { + try { + dispatch({ type: "BEGIN_SEARCH" }); + + const results = await searchResourceProjects(searchTerm, signal); + + // Search is triggered on page load - we don't want to track those as events + if (searchTerm) { + appDispatch( + trackEvent({ action: "Index Search: Core", label: searchTerm }) + ); + } + + if (signal.aborted) { + return; + } + + dispatch({ type: "FINISHED_SEARCHING", payload: results }); + } catch (err) { + if (err.name !== "AbortError") { + console.error("Could not fetch resources", err); + + dispatch({ type: "ERROR" }); + } + } + }; + + let controller: AbortController | null = null; + + const storeObs = fromStore(store); + const subscription = combineLatest([ + merge( + searchTermSubjectRef.current.pipe(skip(1), debounceTime(500)), + searchTermSubjectRef.current.pipe(take(1)), + projectChangeObservable(store).pipe( + withLatestFrom(searchTermSubjectRef.current), + map((pair) => pair[1] ?? "") + ), + storeObs.pipe( + filter(selectProjectLoaded), + map(selectLatestReleaseTag), + distinctUntilChanged(), + withLatestFrom(searchTermSubjectRef.current), + map((pair) => pair[1] ?? "") + ) + ), + storeObs.pipe( + filter(selectProjectLoaded), + map(selectScope[Scope.save]), + distinctUntilChanged() + ), + ]) + .pipe(debounceTime(0)) + .subscribe(([searchTerm, canSave]) => { + controller?.abort(); + + if (canSave) { + controller = new AbortController(); + + search(searchTerm, controller.signal).catch((err) => { + if (err.name !== "AbortError") { + console.error(err); + } + }); + } + }); + + return () => { + controller?.abort(); + subscription.unsubscribe(); + }; + }, [appDispatch, store]); + + useEffect(() => { + searchTermSubjectRef.current.next(searchTerm); + }, [searchTerm]); + + return { + onChange: (searchTerm: string) => + dispatch({ type: "SEARCH", payload: searchTerm }), + loading, + results, + searchTerm, + }; +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/index.ts new file mode 100644 index 0000000..76d88b8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/SearchableIndex/index.ts @@ -0,0 +1 @@ +export { HashCoreResourcesSearchableIndex } from "./HashCoreResourcesSearchableIndex"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Resources/index.ts new file mode 100644 index 0000000..0cf43fc --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/index.ts @@ -0,0 +1,4 @@ +export { HashCoreResources } from "./HashCoreResources"; + +export { HashCoreResourcesList } from "./List"; +export { HashCoreResourcesSearchableIndex } from "./SearchableIndex"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Resources/selectors.ts b/apps/sim-core/packages/core/src/components/HashCore/Resources/selectors.ts new file mode 100644 index 0000000..5975058 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Resources/selectors.ts @@ -0,0 +1,42 @@ +import { createSelector } from "@reduxjs/toolkit"; + +import { ResourceProject } from "../../../features/project/types"; +import type { RootState } from "../../../features/types"; +import { mapLegacyDependencyFormat } from "../../../features/project/utils"; +import { + selectParsedDependencies, + selectPendingDependencies, +} from "../../../features/files/selectors"; + +export const selectPathsForDependencies = createSelector( + [selectParsedDependencies, selectPendingDependencies], + (deps, pending) => [ + ...new Set( + Object.keys(deps) + /** + * This is necessary because some dependencies are specified in a legacy + * "two-part" format (old index behaviors), and we need to know that the + * correctly formatted version of that dependency is included so it + * cannot be added again by the resources picker. + * + * @todo remove this when we remove the old format + */ + .flatMap((dep) => [...new Set([dep, mapLegacyDependencyFormat(dep)])]) + .concat(pending) + ), + ] +); + +export const makeSelectPresentItemsFromResource = () => + createSelector( + selectPathsForDependencies, + (_: RootState, resource: ResourceProject) => resource, + (paths, resource) => + resource.files.reduce((result, file) => { + if (paths.includes(file.path.formatted)) { + result.push(file.path.formatted); + } + + return result; + }, []) + ); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.css b/apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.css new file mode 100644 index 0000000..c892a4c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.css @@ -0,0 +1,65 @@ +.HashCoreSection { + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; +} + +.HashCoreSection-splitter { + position: relative; + flex: auto; + display: flex; + + --hcs-primary-min-width: 172px; + --hcs-primary-min-height: 120px; +} + +.HashCoreSection-splitter + > .splitter-layout:not(.splitter-layout-vertical) + > .layout-pane-primary { + min-width: var(--hcs-primary-min-width); +} + +.HashCoreSection-splitter > .splitter-layout-vertical > .layout-pane-primary { + min-height: var(--hcs-primary-min-height); +} + +.HashCoreSection-splitter + > .splitter-layout:not(.splitter-primary-hidden):not(.splitter-layout-vertical) + > .layout-pane:not(.layout-pane-primary) { + max-width: calc(100% - 1px - var(--hcs-primary-min-width)); +} +.HashCoreSection-splitter + > .splitter-layout-vertical:not(.splitter-primary-hidden) + > .layout-pane:not(.layout-pane-primary) { + max-height: calc(100% - 1px - var(--hcs-primary-min-height)); +} + +.HashCoreSection-splitter .layout-pane-primary.overflow-visible { + overflow: visible; +} + +.HashCoreSection .tab-button { + background-color: transparent; + border: none; + + padding: 1px; +} + +.HashCoreSection .tab-button .IconClose { + margin-left: 6px; +} + +.HashCoreSection * + .tab-button { + margin-left: 12px; +} + +.HashCoreSection .tab-button { + fill: var(--theme-grey); + transition: fill 0.1s; +} + +.HashCoreSection .tab-button:hover { + fill: var(--theme-white); +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.tsx b/apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.tsx new file mode 100644 index 0000000..a91ecb8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Section/HashCoreSection.tsx @@ -0,0 +1,61 @@ +import React, { FC, useState } from "react"; +import { useSelector } from "react-redux"; + +import { HashCoreEditorContainer } from "../EditorContainer/HashCoreEditorContainer"; +import { HashCoreViewer } from "../Viewer/HashCoreViewer"; +import { WrappedSplitterLayout } from "../../WrappedSplitterLayout/WrappedSplitterLayout"; +import { selectDisplayEditorSection } from "../../../features/selectors"; +import { + selectEditorVisible, + selectEmbedded, + selectViewerVisible, +} from "../../../features/viewer/selectors"; +import { useResizeObserver } from "../../../hooks/useResizeObserver/useResizeObserver"; + +import "./HashCoreSection.css"; + +export const HashCoreSection: FC = () => { + const editorVisible = useSelector(selectEditorVisible); + const displayEditorSection = useSelector(selectDisplayEditorSection); + const embedded = useSelector(selectEmbedded); + const [vertical, setVertical] = useState(false); + const viewerVisible = useSelector(selectViewerVisible); + + const ref = useResizeObserver(({ width }) => setVertical(width <= 700), { + onObserve: null, + }); + + const components = [ + , + , + ]; + + const actuallyVertical = embedded && vertical; + + let primaryIndex: 0 | 1 = 0; + + if (actuallyVertical) { + components.reverse(); + primaryIndex = 1; + } + + return ( +
    +
    + + {components} + +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Section/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Section/index.ts new file mode 100644 index 0000000..9c35fc2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Section/index.ts @@ -0,0 +1 @@ +export { HashCoreSection } from "./HashCoreSection"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.css b/apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.css new file mode 100644 index 0000000..b754be3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.css @@ -0,0 +1,241 @@ +.shepherd-button { + background: var(--theme-blue); + border: 0; + border-radius: var(--button-border-radius); + color: white; + text-transform: uppercase; + font-weight: bold; + cursor: pointer; + user-select: none; + margin-left: 0.5rem; + padding: 10px 19px; +} +.shepherd-button:not(:disabled):hover { + background: var(--theme-blue-hover); +} +.shepherd-button.secondary { + background: rgba(255, 255, 255, 0.12); +} +.shepherd-button:disabled { + opacity: 0.8; + color: rgba(255, 255, 255, 0.7); + cursor: not-allowed; +} +.shepherd-button.secondary:not(:disabled):hover { + background: var(--theme-white); + color: var(--theme-black); +} +.shepherd-footer { + display: flex; + justify-content: flex-start; + margin-top: 15px; +} +.shepherd-footer .shepherd-button:first-child { + margin-left: 0; +} +.shepherd-cancel-icon { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + user-select: none; + font-weight: 400; + margin: 0; + padding: 0; + + position: absolute; + top: 0; + right: 5px; + font-size: 25px; +} +.shepherd-text { + font-size: 14px; + line-height: 1.3; +} +.shepherd-text p { + margin-top: 0; +} +.shepherd-text p:last-child { + margin-bottom: 0; +} +.shepherd-content { + border-radius: 5px; + outline: none; + padding: 0; +} +.shepherd-element { + --border-radius: 8px; + background: var(--theme-dark); + border-radius: var(--border-radius); + padding: 20px 25px; + color: var(--theme-white); + border: 1px solid var(--theme-border); + width: 400px; + opacity: 0; + outline: none; + z-index: 303; + position: absolute; +} +.shepherd-enabled.shepherd-element { + opacity: 1; +} +.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered) { + opacity: 0; +} +.shepherd-element, +.shepherd-element * { + box-sizing: border-box; +} +.shepherd-arrow { + display: none; +} +.shepherd-element a { + text-decoration: none; + font-weight: bold; + color: var(--theme-blue); + border-bottom: 2px solid transparent; + transition: all 0.1s ease-in-out; +} + +.shepherd-element a:hover { + border-bottom-color: var(--theme-blue); +} + +.shepherd-target-click-disabled.shepherd-enabled.shepherd-target, +.shepherd-target-click-disabled.shepherd-enabled.shepherd-target * { + pointer-events: none; +} + +.shepherd-modal-overlay-container { + fill-rule: evenodd; + height: 0; + left: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: fixed; + top: 0; + transition: all 0.1s ease-out, height 0ms 0.1s, opacity 0.1s 0ms; + width: 100vw; + z-index: 9997; +} +.shepherd-modal-overlay-container.shepherd-modal-is-visible { + height: 100vh; + opacity: 0.5; + transition: all 0.1s ease-out, height 0s 0s, opacity 0.1s 0s; +} +.shepherd-modal-overlay-container.shepherd-modal-is-visible path { + pointer-events: all; +} + +.HashCoreTour-backdrop { + z-index: 303; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + opacity: 1; + transition: all 0.1s ease; +} + +.HashCoreTour-backdrop--hidden { + opacity: 0; + pointer-events: none; +} + +.HashCoreTour-Progress { + position: absolute; + right: 25px; + width: 179px; + bottom: 28px; + height: 20px; + border-radius: 10px; + background-color: var(--theme-darkest); + overflow: hidden; +} + +.HashCoreTour-Progress-Indicator { + height: 100%; + background-color: var(--theme-blue); + width: var(--tour-prev-progress); + + animation: HashCoreTour-Progress 0.1s ease-out forwards; +} + +@keyframes HashCoreTour-Progress { + 100% { + width: var(--tour-progress); + } +} + +.HashCoreTour-Indicator { + position: absolute; + z-index: 300; + pointer-events: none; + opacity: 0; + transition: opacity 0.1s ease-in; + box-sizing: border-box; +} + +.HashCoreTour-Indicator--showing { + opacity: 1; + transition-timing-function: ease-out; +} + +.HashCoreTour-Indicator--dot { + width: 20px; + height: 20px; + background-color: rgba(30, 119, 255, 1); + transform: scale(1); + transform-origin: center; + border-radius: 50%; + box-shadow: 0 0 0 0 rgba(30, 119, 255, 1); + animation: HashCoreTour-Indicator--dot 2s infinite; +} + +@keyframes HashCoreTour-Indicator--dot { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(30, 119, 255, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(30, 119, 255, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(30, 119, 255, 0); + } +} + +.HashCoreTour-Center .shepherd-element { + padding: 55px; + width: 635px; +} + +.HashCoreTour-Center .shepherd-button { + padding: 20px 25px; +} +.HashCoreTour-Center .shepherd-footer { + margin-top: 30px; +} +html:not(.HashCoreTour-Center) .shepherd-element { + top: auto !important; + right: auto !important; + transform: none !important; + left: calc(var(--left-pane-width) + 10px) !important; + bottom: 10px !important; +} + +.HashCoreTour__AvatarPreload { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + overflow: hidden; +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.tsx new file mode 100644 index 0000000..2dca3cc --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/HashCoreTour.tsx @@ -0,0 +1,359 @@ +import React, { + FC, + Fragment, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { useDispatch, useSelector } from "react-redux"; + +import { Avatar, CloseButton, VERSION, steps } from "./Step"; +import { + HashCoreTourConfig, + HashTourConfigContext, + HashTourConfigContextType, + ShepherdTour, + Tour, + TourShowEvent, + useTour, +} from "./react-shepherd-wrapper"; +import type { TourProgress } from "../../../util/api/types"; +import { getTourShowcase } from "../../../util/api"; +import { + selectCurrentProject, + selectProjectLoaded, +} from "../../../features/project/selectors"; +import { selectTourProgress } from "../../../features/user/selectors"; +import { tourProgress } from "../../../features/user/thunks"; +import { urlFromProject } from "../../../routes"; +import { useGettingStartedProject } from "./util"; +import { usePromise } from "../../../hooks/usePromise"; +import { useSafeQueryParams } from "../../../hooks/useSafeQueryParams"; + +const tourOptions = { + defaultStepOptions: { + cancelIcon: { enabled: true, label: "Exit" }, + arrow: false, + }, + exitOnEsc: false, + keyboardNavigation: false, +}; + +const TOUR_HIDDEN_IDX = -1; +const useTourPosition = (tour: Tour): [number, number, boolean] => { + const [{ activeIdx, prevIdx, stepId }, update] = useReducer( + ( + state: S, + action: S + ) => ({ ...state, ...action }), + { activeIdx: TOUR_HIDDEN_IDX, prevIdx: TOUR_HIDDEN_IDX, stepId: null } + ); + + const isVisible = activeIdx !== TOUR_HIDDEN_IDX; + + const activeIdxRef = useRef(activeIdx); + activeIdxRef.current = activeIdx; + + const { steps } = tour; + + useEffect(() => { + const onShow = ({ step, previous }: TourShowEvent) => { + const idx = steps.indexOf(step); + + update({ + activeIdx: idx, + prevIdx: previous ? steps.indexOf(previous) : 0, + stepId: step.options.id ?? `Step${idx}`, + }); + }; + + const onHide = () => + update({ + activeIdx: TOUR_HIDDEN_IDX, + prevIdx: activeIdxRef.current, + stepId: null, + }); + + tour.on("show", onShow); + tour.on("cancel", onHide); + tour.on("complete", onHide); + + return () => { + tour.off("show", onShow); + tour.off("cancel", onHide); + tour.off("complete", onHide); + }; + }, [tour, steps, update]); + + useEffect(() => { + if (!stepId) { + return; + } + + const className = `HashCoreTour-Step-${stepId}`; + + document.documentElement.classList.add(className); + + return () => { + document.documentElement.classList.remove(className); + }; + }, [stepId]); + + return [activeIdx, prevIdx, isVisible]; +}; + +const useAutoTriggerTour = (tour: Tour, isVisible: boolean) => { + const project = useSelector(selectCurrentProject); + const projectLoaded = useSelector(selectProjectLoaded); + const tourProgress = useSelector(selectTourProgress); + + const [ + { triggerTour, fromOnboardingRoute }, + setQueryParams, + ] = useSafeQueryParams(); + + const gettingStartedSim = useGettingStartedProject(); + const gettingStarted = [ + project?.pathWithNamespace, + project?.forkOf?.pathWithNamespace, + ].includes(gettingStartedSim?.pathWithNamespace); + + const gettingStartedRef = useRef(gettingStarted); + const isVisibleRef = useRef(isVisible); + + useEffect(() => { + if (isVisibleRef.current && !isVisible && triggerTour) { + setQueryParams({ triggerTour: undefined }); + } + + isVisibleRef.current = isVisible; + }, [isVisible, setQueryParams, triggerTour]); + + useEffect(() => { + const triggeringManually = triggerTour !== undefined; + + if ( + tour.isActive() || + !(projectLoaded && (triggeringManually || gettingStarted)) + ) { + return; + } + + const { completed, version } = tourProgress ?? {}; + + if (triggeringManually || !completed) { + const lastStepViewed = + ((!completed && version === VERSION && tourProgress?.lastStepViewed) || + triggerTour + ? tour.getById(triggerTour ?? tourProgress?.lastStepViewed) + : null + )?.options.id ?? steps[0].id; + + gettingStartedRef.current = gettingStarted; + + tour.start(); + tour.show(lastStepViewed); + } + + if (triggeringManually && fromOnboardingRoute) { + setQueryParams({ + triggerTour: undefined, + fromOnboardingRoute: undefined, + }); + } + }, [ + triggerTour, + tour, + gettingStarted, + projectLoaded, + fromOnboardingRoute, + setQueryParams, + tourProgress, + ]); + + useEffect(() => { + const wasGettingStarted = gettingStartedRef.current; + gettingStartedRef.current = gettingStarted; + + if (tour.isActive() && wasGettingStarted && !gettingStarted) { + tour.cancel(); + } + }, [gettingStarted, tour]); +}; + +const useSyncProgressBar = (activeIdx: number, prevIdx: number) => { + useEffect(() => { + document.documentElement.style.setProperty( + "--tour-progress", + `${((activeIdx + 1) / steps.length) * 100}%` + ); + + document.documentElement.style.setProperty( + "--tour-prev-progress", + `${((prevIdx + 1) / steps.length) * 100}%` + ); + }, [activeIdx, prevIdx]); +}; + +const useHashTourConfig = (isVisible: boolean): HashTourConfigContextType => { + const tourShowcase = usePromise(getTourShowcase, isVisible); + const initialState = { + shouldShowBackdrop: false, + shouldCenter: false, + }; + const [{ shouldShowBackdrop, shouldCenter }, update] = useReducer( + (state: HashCoreTourConfig, action: HashCoreTourConfig | null) => + action + ? { + ...state, + ...action, + } + : initialState, + initialState + ); + + useEffect(() => { + if (shouldCenter) { + document.documentElement.classList.add("HashCoreTour-Center"); + } else { + document.documentElement.classList.remove("HashCoreTour-Center"); + } + }, [shouldCenter]); + + return useMemo( + () => ({ + tourShowcase, + update, + isVisible, + config: { + shouldShowBackdrop, + shouldCenter, + }, + }), + [shouldCenter, shouldShowBackdrop, tourShowcase, isVisible] + ); +}; + +const useIsCompleted = ( + tour: Tour, + tourProgress: TourProgress | null, + activeIdx: number +) => { + const { completed = false } = tourProgress ?? {}; + const [isCompleted, setIsCompleted] = useState(completed); + + useEffect(() => { + setIsCompleted(completed); + }, [completed]); + + useEffect(() => { + const { steps } = tour; + + if (tour.isActive() && !steps[activeIdx + 1] && !isCompleted) { + setIsCompleted(true); + } + }, [activeIdx, tour, isCompleted]); + + return isCompleted; +}; + +const useTrackProgress = ( + tour: Tour, + activeIdx: number, + prevIdx: number, + isCompleted: boolean +) => { + const dispatch = useDispatch(); + + useEffect(() => { + if (!tour.isActive() || (activeIdx === 0 && prevIdx <= activeIdx)) { + return; + } + + /** + * If we've previously completed it and are now just reviewing it, + * we don't want to overwrite their progress + */ + const currentStep = isCompleted ? tour.steps.length - 1 : activeIdx; + const lastStepViewed = tour.steps[currentStep].options.id ?? ""; + + dispatch( + tourProgress({ + completed: isCompleted, + version: VERSION, + lastStepViewed, + }) + ); + }, [activeIdx, dispatch, isCompleted, prevIdx, tour]); +}; + +const TourWithBackdrop: FC = () => { + const tour = useTour(); + const tourProgress = useSelector(selectTourProgress); + const [activeIdx, prevIdx, isVisible] = useTourPosition(tour); + const hashTourConfig = useHashTourConfig(isVisible); + const isCompleted = useIsCompleted(tour, tourProgress, activeIdx); + + useAutoTriggerTour(tour, isVisible); + useSyncProgressBar(activeIdx, prevIdx); + useTrackProgress(tour, activeIdx, prevIdx, isCompleted); + + return ( + + {createPortal( + <> +
    { + if (isCompleted) { + tour.cancel(); + } + }} + /> +
    + {hashTourConfig.tourShowcase?.map( + ({ avatar, thumbnail, pathWithNamespace, ref }) => ( + + ) + )} +
    + , + document.body + )} + {steps.map((step, idx) => ( + + {createPortal( + idx === activeIdx ? ( + <> + {isCompleted ? : null} + {step.jsx} + + ) : null, + step.text + )} + + ))} + + ); +}; + +export const HashCoreTour: FC = ({ children }) => ( + + + {children} + +); diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepAgents.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepAgents.tsx new file mode 100644 index 0000000..8ba2dfa --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepAgents.tsx @@ -0,0 +1,52 @@ +import React, { FC } from "react"; +import { useSelector } from "react-redux"; + +import { + BackButton, + Button, + Buttons, + Indicator, + ProgressIndicator, + useDomElementForFileId, + useKeyboardSupport, +} from "./util"; +import { selectCurrentFileId } from "../../../../features/files/selectors"; + +export const HashCoreTourStepAgents: FC = () => { + const initialStateFile = useDomElementForFileId("initialState"); + const currentFileId = useSelector(selectCurrentFileId); + const initialStateSelected = currentFileId === "initialState"; + + useKeyboardSupport(); + + return ( + <> + +

    + + HASH is oriented around agents. + {" "} + You create agents that act and interact within your simulation. +

    +

    + The `init` file, found in the 'src' folder on the left hand side, is + where you define your initial agents. +

    +

    + Open the `init` file to continue. +

    + + + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDatasets.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDatasets.tsx new file mode 100644 index 0000000..b7b0f80 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDatasets.tsx @@ -0,0 +1,54 @@ +import React, { FC } from "react"; +import { useSelector } from "react-redux"; +import urljoin from "url-join"; + +import { + BackButton, + Button, + Buttons, + KeyboardSupport, + ProgressIndicator, +} from "./util"; +import { SITE_URL } from "../../../../util/api/paths"; +import { selectUserProfileUrl } from "../../../../features/user/selectors"; + +export const HashCoreTourStepDatasets: FC = () => { + const url = useSelector(selectUserProfileUrl); + + return ( + <> + +

    + To tailor a simulation with real-world data,{" "} + + you can import datasets to customize your agents and behaviors + + . Data, behaviors, and simulations that others users have shared are + also available in{" "} + + HASH + + , which you can add to your own simulations. +

    +

    + Your simulation and datasets are auto-saved to your{" "} + {url ? ( + + profile + + ) : ( + <>profile + )} + , where you can share them with the world! +

    + + + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.css b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.css new file mode 100644 index 0000000..290cab0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.css @@ -0,0 +1,92 @@ +.HashCoreTour-Center.HashCoreTour-Step-done .shepherd-element { + padding: 0; + width: 740px; +} + +.HashCoreTourDone { + text-align: center; + font-size: 14px; + padding: 65px 45px; +} + +.HashCoreTourDone h2 { + font-size: 24px; + margin: 0 0 10px; +} + +.HashCoreTourDone p { + margin-bottom: 30px; + line-height: 1.5; +} + +.HashCoreTourStepDoneShowcase { + display: flex; + justify-content: center; +} + +.HashCoreTourStepDoneShowcase a, +.HashCoreTourStepDoneShowcase a:hover { + color: inherit; + text-decoration: none; + border: none; +} + +.HashCoreTourStepDoneShowcase__Sim { + --size: 150px; + font-size: 14px; + width: var(--size); + display: block; +} + +.HashCoreTourStepDoneShowcase__Sim:not(:first-child) { + margin-left: 15px; +} + +.HashCoreTourStepDoneShowcase__Sim__Thumb { + height: var(--size); + border-radius: 5px; + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 46px; + color: rgba(255, 255, 255, 0.33); + position: relative; + overflow: hidden; +} + +.HashCoreTourStepDoneShowcase__Sim__Thumb img, +.HashCoreTourStepDoneShowcase__Sim__Thumb video { + position: absolute; + top: 0; + bottom: 0; + width: auto; + height: 100%; + left: 50%; + transform: translateX(-50%); + pointer-events: none; +} + +.HashCoreTourStepDoneShowcase__Sim__Name { + max-width: 100%; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.HashCoreTourStepDoneShowcase__Sim--Create + .HashCoreTourStepDoneShowcase__Sim__Thumb { + background-color: var(--theme-black); +} + +.HashCoreTourDoneButton { + width: 100%; + border: none; + border-top: 1px solid var(--theme-border); + padding: 18px 0; + text-align: center; + font-size: 14px; + color: white; + border-radius: 0 0 var(--border-radius) var(--border-radius); +} diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.tsx new file mode 100644 index 0000000..dcc5dd3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepDone.tsx @@ -0,0 +1,105 @@ +import React, { FC, MouseEventHandler, ReactNode } from "react"; + +import { Avatar, useKeyboardSupport } from "./util"; +import { Link } from "../../../Link/Link"; +import { Scope } from "../../../../features/scopes"; +import { urlFromProject } from "../../../../routes"; +import { useConfigHashTourForSlide, useTour } from "../react-shepherd-wrapper"; + +import "./HashCoreTourStepDone.css"; + +const ShowcaseItem: FC<{ + name: string; + thumb?: ReactNode; + path?: string; + scope?: Scope; + onClick?: MouseEventHandler; + className?: string; +}> = ({ name, thumb, onClick, path = "#", scope, className = "" }) => ( + +
    {thumb}
    +
    + {name} +
    + +); + +export const HashCoreTourStepDone: FC = () => { + const tour = useTour(); + + const { tourShowcase } = useConfigHashTourForSlide({ + shouldShowBackdrop: true, + shouldCenter: true, + }); + + useKeyboardSupport(true, false); + + return ( + <> +
    +

    Congratulations!

    +

    + Now you're ready to start building simulations with HASH! +
    + Check out our{" "} + + Getting Started tutorial + {" "} + for more on getting set up. +
    + You can replay this tour at any time from the 'Help' menu at the top + of the page. +

    +
    + {tourShowcase?.map( + ({ pathWithNamespace, ref, avatar, thumbnail, name }) => ( + { + tour.complete(); + }} + key={urlFromProject({ + pathWithNamespace, + ref, + })} + thumb={} + /> + ) + )} + { + tour.complete(); + }} + thumb="+" + className="HashCoreTourStepDoneShowcase__Sim--Create" + /> +
    +
    + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepIntro.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepIntro.tsx new file mode 100644 index 0000000..b791f7c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepIntro.tsx @@ -0,0 +1,31 @@ +import React, { FC } from "react"; + +import { Button, Buttons, useKeyboardSupport } from "./util"; +import { useConfigHashTourForSlide } from "../react-shepherd-wrapper"; + +export const HashCoreTourStepIntro: FC = () => { + useConfigHashTourForSlide({ + shouldCenter: true, + shouldShowBackdrop: true, + }); + + useKeyboardSupport(false, true); + + return ( + <> +

    + Welcome to HASH Core, our simulation development and + experimentation environment. +

    +

    + + You can replay this tutorial at anytime by selecting ‘New user tour’ + from the help menu. + +

    + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPause.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPause.tsx new file mode 100644 index 0000000..8dd2943 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPause.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; + +import { + BackButton, + Button, + Buttons, + ProgressIndicator, + useKeyboardSupport, + useSimulationPause, +} from "./util"; + +export const HashCoreTourStepPause: FC = () => { + useKeyboardSupport(); + useSimulationPause(); + + return ( + <> +

    Congratulations, you just ran a simulation!

    +

    + Small changes to parameters can have a huge impact on simulation + outcomes. Parameter values are set in the globals.json file. +

    +

    + + We've paused your simulation for you, but normally this will keep + running until stopped. + +

    + + + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlay.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlay.tsx new file mode 100644 index 0000000..ba6a308 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlay.tsx @@ -0,0 +1,43 @@ +import React, { FC, useState } from "react"; + +import { + BackButton, + Button, + Buttons, + PlayIndicator, + ProgressIndicator, + useKeyboardSupport, + useOnSimulationPlay, +} from "./util"; + +export const HashCoreTourStepPlay: FC = () => { + const [played, setPlayed] = useState(false); + + useOnSimulationPlay(() => { + setPlayed(true); + }, [setPlayed]); + + useKeyboardSupport(); + + return ( + <> + +

    + We've defined our agents, given them behaviors, and are ready to run the + simulation. +

    +

    + The viewer on the right shows you what's happening step-by-step within + the simulation. +

    +

    + Click the run button below the view pane to continue.{" "} +

    + + + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlots.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlots.tsx new file mode 100644 index 0000000..be1d5ae --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepPlots.tsx @@ -0,0 +1,75 @@ +import React, { FC, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { + BackButton, + Button, + Buttons, + Indicator, + ProgressIndicator, + useKeyboardSupport, + useSimulationPause, +} from "./util"; +import { TabKind } from "../../../../features/viewer/enums"; +import { addTab, selectCurrentTab } from "../../../../features/viewer"; + +const usePlotTab = (): [boolean, HTMLElement | null] => { + const dispatch = useDispatch(); + const currentTab = useSelector(selectCurrentTab); + const currentTabKind = currentTab === TabKind.Analysis; + + /** + * Using state for this as we need to re-render when this changes to position + * the indicator + */ + const [elem, setElem] = useState(null); + + // @todo this may call too frequently + useEffect(() => { + dispatch(addTab(TabKind.Analysis)); + + const elem = Array.from( + document.querySelectorAll( + ".SimulationViewerMain .react-tabs__tab" + ) + ).find((tab) => tab.innerText.includes("Plots")); + + if (elem) { + setElem(elem); + } + }); + + return [currentTabKind, elem]; +}; + +export const HashCoreTourStepPlots: FC = () => { + const [plotTabSelected, plotTab] = usePlotTab(); + + useKeyboardSupport(); + useSimulationPause(); + + return ( + <> + +

    + You can visualize how particular variables or parameters of interest + change over the lifetime of your simulation by switching to the + 'Analysis' view. +

    +

    + + Switch to the 'Analysis' view by clicking the tab above the viewer. + +

    + + + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepRefresh.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepRefresh.tsx new file mode 100644 index 0000000..a68288e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/HashCoreTourStepRefresh.tsx @@ -0,0 +1,118 @@ +import React, { FC, useEffect, useMemo, useReducer } from "react"; +import { useSelector } from "react-redux"; + +import { + BackButton, + Button, + Buttons, + Indicator, + PlayIndicator, + ProgressIndicator, + useDomElementForFileId, + useKeyboardSupport, + useOnSimulationPlay, + useOnSimulationReset, +} from "./util"; +import { globalsFileId } from "../../../../features/files/utils"; +import { selectCurrentFileId } from "../../../../features/files/selectors"; + +const refreshInitialState = { + hasOpenedProperties: false, + hasReset: false, + hasPlayed: false, +}; + +enum RefreshAction { + OPEN_PROPERTIES = "OPEN_PROPERTIES", + PLAYED = "SIMULATION_PLAYED", + RESET = "SIMULATION_RESET", +} + +function refreshReducer( + state: typeof refreshInitialState, + action: RefreshAction +) { + switch (action) { + case RefreshAction.OPEN_PROPERTIES: + return { ...state, hasOpenedProperties: true }; + + case RefreshAction.RESET: + return { ...state, hasReset: state.hasOpenedProperties }; + + case RefreshAction.PLAYED: + return { ...state, hasPlayed: state.hasReset }; + } +} + +export const HashCoreTourStepRefresh: FC = () => { + const [state, dispatch] = useReducer(refreshReducer, refreshInitialState); + const propertiesFile = useDomElementForFileId(globalsFileId); + const currentFileId = useSelector(selectCurrentFileId); + + useKeyboardSupport(); + + useOnSimulationReset(() => { + dispatch(RefreshAction.RESET); + }, []); + + useOnSimulationPlay(() => { + dispatch(RefreshAction.PLAYED); + }, []); + + useEffect(() => { + if (currentFileId === globalsFileId) { + dispatch(RefreshAction.OPEN_PROPERTIES); + } + }, [currentFileId]); + + const resetButton = useMemo( + () => document.querySelector(".reset.simulation-control"), + [] + ); + + return ( + <> + + + +

    + + globals.json is where you define global properties + {" "} + within your simulated world. +

    +

    + These properties are accessible to all agents through ' + + context + + '. +

    +

    + + Change a property, then click the 'reset' and 'run' buttons to see + what happens. + +

    + + + + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/index.ts b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/index.ts new file mode 100644 index 0000000..66cd3a4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/index.ts @@ -0,0 +1,2 @@ +export { steps, VERSION } from "./steps"; +export * from "./util"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/steps.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/steps.tsx new file mode 100644 index 0000000..a751135 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/steps.tsx @@ -0,0 +1,142 @@ +import React from "react"; + +import { + BackButton, + Button, + Buttons, + KeyboardSupport, + ProgressIndicator, +} from "./util"; +import { HashCoreTourStepAgents } from "./HashCoreTourStepAgents"; +import { HashCoreTourStepDatasets } from "./HashCoreTourStepDatasets"; +import { HashCoreTourStepDone } from "./HashCoreTourStepDone"; +import { HashCoreTourStepIntro } from "./HashCoreTourStepIntro"; +import { HashCoreTourStepPause } from "./HashCoreTourStepPause"; +import { HashCoreTourStepPlay } from "./HashCoreTourStepPlay"; +import { HashCoreTourStepPlots } from "./HashCoreTourStepPlots"; +import { HashCoreTourStepRefresh } from "./HashCoreTourStepRefresh"; + +export const steps = [ + { + id: "intro", + text: , + }, + { + id: "languages", + text: ( + <> + +

    + + HASH Core features a live code editor for creating, editing, and + running simulations in your browser. + +

    +

    + Core supports JavaScript or Python - when you create a new file you + can select the file extension for the language of your choice (this + tutorial will be in JavaScript). +

    + + + + + + + ), + }, + { + id: "agents", + text: , + }, + { + id: "behaviors", + text: ( + <> + +

    + Every agent has a state (which you set as a JSON object) and (often) + has behaviors. +

    +

    + + Behaviors are “actions” that, if attached to an agent, run every + timestep. + {" "} + They’re functions which take in the current state and context of the + agent, perform an action, and return the next state of the agent. +

    + + + + + + + ), + }, + { + id: "play", + text: , + }, + { + id: "pause", + text: , + }, + { + id: "refresh", + text: , + }, + { + id: "plots", + text: , + }, + { + id: "plots-desc", + text: ( + <> + +

    + + HASH plots charts from your simulation + + , letting you observe patterns and answer questions about your + simulation. For instance does a certain variable converge to a single + value? Or what's the optimal parameter setting for a given scenario? +

    +

    + HASH autogenerates initial outputs and plots, any of which you can + customize and expand by editing analysis.json. +

    + + + + + + + ), + }, + { + id: "datasets", + text: , + }, + { + id: "done", + text: , + }, +].map((step) => ({ + id: `${step.id}`, + buttons: [], + cancelIcon: { + enabled: false, + }, + text: document.createElement("div"), + jsx: step.text, +})); + +export const VERSION = "1.1"; diff --git a/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/util.tsx b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/util.tsx new file mode 100644 index 0000000..8e4da3d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/HashCore/Tour/Step/util.tsx @@ -0,0 +1,352 @@ +import React, { FC, useEffect, useMemo, useRef, useState } from "react"; + +import { getDomIdByFileId } from "../../Files/ListItemFile"; +import { pauseSimulator } from "../../../../features/simulator/simulate/slice"; +import { + selectCurrentSimulationId, + selectRunning, +} from "../../../../features/simulator/simulate/selectors"; +import { useResizeObserver } from "../../../../hooks/useResizeObserver/useResizeObserver"; +import { + useSimulatorDispatch, + useSimulatorSelector, +} from "../../../../features/simulator/context"; +import { useTour } from "../react-shepherd-wrapper"; + +function getScrollParent(node: HTMLElement): HTMLElement { + if (node === document.body || node.scrollHeight > node.clientHeight) { + return node; + } else { + return getScrollParent(node.parentNode as HTMLElement); + } +} + +/** + * @todo Couldn't figure out how to animate out when slide changes + * within React lifecycles. Rendering manually instead + * @todo Rewrite this + */ +export const Indicator: FC<{ + element: HTMLElement | null | undefined; + show: boolean; + position: "left-overlap" | "right-overlap" | "right"; +}> = ({ element, show, position }) => { + const calculateRef = useRef(null); + const immediateIdRef = useRef | null>(null); + + const calculateOnResize = () => { + if (immediateIdRef.current !== null) { + clearImmediate(immediateIdRef.current); + } + + immediateIdRef.current = setImmediate(() => calculateRef.current?.()); + }; + + const setTargetObserverRef = useResizeObserver(calculateOnResize, { + onObserve: null, + }); + const setElementParentObserverRef = useResizeObserver(calculateOnResize, { + onObserve: null, + }); + + const indicator = useMemo(() => { + const div = document.createElement("div"); + + div.classList.add("HashCoreTour-Indicator"); + div.classList.add(`HashCoreTour-Indicator--dot`); + + return div; + }, []); + + useEffect(() => { + if (!element) { + setTargetObserverRef(null); + setElementParentObserverRef(null); + calculateRef.current = null; + + return; + } + + const target = getScrollParent(element); + + if (window.getComputedStyle(target).position === "static") { + target.style.position = "relative"; + } + + calculateRef.current = () => { + // We don't have scroll bars on the body – this prevents weird behavior + if (target !== document.body) { + target.scrollLeft = element.offsetLeft; + target.scrollTop = element.offsetTop; + } + + const elementRect = element.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + + const elementPosition = { + top: elementRect.top - targetRect.top + target.scrollTop, + left: elementRect.left - targetRect.left + target.scrollLeft, + }; + + // @todo abstract to function + let left: number; + let top: number; + switch (position) { + case "left-overlap": + left = elementPosition.left - indicator.offsetWidth / 2; + top = elementPosition.top - indicator.offsetHeight / 2; + break; + + case "right-overlap": + left = + elementPosition.left + + elementRect.width - + indicator.offsetWidth / 2; + top = elementPosition.top - indicator.offsetHeight / 2; + break; + + case "right": + left = + elementPosition.left + + elementRect.width - + indicator.offsetWidth - + 15; + top = + elementPosition.top + + (elementRect.height - indicator.offsetHeight) / 2; + break; + } + + Object.assign(indicator.style, { + top: `${top}px`, + left: `${left}px`, + }); + }; + + setTargetObserverRef(target); + setElementParentObserverRef(element.offsetParent as HTMLElement | null); + target.appendChild(indicator); + calculateRef.current(); + }, [ + element, + indicator, + position, + setElementParentObserverRef, + setTargetObserverRef, + ]); + + useEffect(() => { + return () => { + setElementParentObserverRef(null); + setTargetObserverRef(null); + + indicator.addEventListener("transitionend", () => { + indicator.remove(); + }); + + indicator.classList.remove("HashCoreTour-Indicator--showing"); + }; + }, [indicator, setElementParentObserverRef, setTargetObserverRef]); + + useEffect(() => { + const shouldShow = element && show; + + if (shouldShow) { + calculateRef.current?.(); + } + + indicator.classList[shouldShow ? "add" : "remove"]( + "HashCoreTour-Indicator--showing" + ); + }, [element, show, indicator]); + + return null; +}; + +export const PlayIndicator: FC<{ show: boolean }> = ({ show }) => { + const element = useMemo( + () => document.querySelector(".simulation-control.simulate"), + [] + ); + + return ; +}; + +export const ProgressIndicator: FC = () => { + return ( +
    +
    +
    + ); +}; + +export const Buttons: FC = ({ children }) => ( +
    {children}
    +); + +export const Button: FC<{ + type: "back" | "next"; + className?: string; + disabled?: boolean; +}> = ({ type, className = "", disabled = false, children }) => { + const tour = useTour(); + + return ( + + ); +}; + +export const BackButton: FC<{ + disabled?: boolean; +}> = ({ disabled }) => ( + +); + +export const useKeyboardSupport = (canGoBack = true, canGoForward = true) => { + const tour = useTour(); + + useEffect(() => { + function listener(evt: KeyboardEvent) { + switch (evt.key) { + case "ArrowLeft": + if (canGoBack) { + evt.preventDefault(); + evt.stopImmediatePropagation(); + evt.stopPropagation(); + + tour.back(); + } + break; + + case "ArrowRight": + if (canGoForward) { + evt.preventDefault(); + evt.stopImmediatePropagation(); + evt.stopPropagation(); + + tour.next(); + } + break; + } + } + + document.body.addEventListener("keydown", listener); + + return () => { + document.body.removeEventListener("keydown", listener); + }; + }, [canGoBack, canGoForward, tour]); +}; + +export const KeyboardSupport: FC = () => { + useKeyboardSupport(true, true); + + return null; +}; + +export const useOnSimulationPlay = (callback: VoidFunction, memo: any[]) => { + const memoCallback = useMemo(() => callback, memo); + const running = useSimulatorSelector(selectRunning); + + useEffect(() => { + if (running) { + memoCallback(); + } + }, [memoCallback, running]); +}; + +export const useOnSimulationReset = (callback: VoidFunction, memo: any[]) => { + const memoCallback = useMemo(() => callback, memo); + + const simId = useSimulatorSelector(selectCurrentSimulationId); + const activeRunIdRef = useRef(simId); + + useEffect(() => { + if (simId !== activeRunIdRef.current) { + activeRunIdRef.current = simId; + memoCallback(); + } + }, [simId, memoCallback]); +}; + +export const useDomElementForFileId = (fileId: string): HTMLElement | null => { + const [file, setFile] = useState(null); + + useEffect(() => { + const file = document.getElementById(getDomIdByFileId(fileId)); + + if (file) { + setFile(file); + } else { + console.warn(`file with id ${fileId} does not exist`); + } + }, [fileId]); + + return file; +}; + +export const useSimulationPause = () => { + const simulatorDispatch = useSimulatorDispatch(); + const simId = useSimulatorSelector(selectCurrentSimulationId); + const running = useSimulatorSelector(selectRunning); + + useEffect(() => { + if (running) { + simulatorDispatch(pauseSimulator({ simId })); + } + }, [simulatorDispatch, running, simId]); +}; + +export const CloseButton: FC = () => { + const tour = useTour(); + + return ( + + ); +}; + +const canPlayWebm = (() => { + const video = document.createElement("video"); + + return video.canPlayType(`video/webm; codecs="vp8, vorbis"`) === "probably"; +})(); + +export const Avatar: FC<{ + avatar: string | undefined; + thumbnail: string | undefined; +}> = ({ thumbnail, avatar }) => { + if (!avatar && !thumbnail) { + return null; + } + + const webm = avatar?.endsWith(".webm"); + const mp4 = avatar?.endsWith(".mp4"); + + return mp4 || (webm && canPlayWebm) ? ( +
    + ); +}); diff --git a/apps/sim-core/packages/core/src/components/Inputs/Select/types.ts b/apps/sim-core/packages/core/src/components/Inputs/Select/types.ts new file mode 100644 index 0000000..d45855c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Inputs/Select/types.ts @@ -0,0 +1,18 @@ +import { DetailedHTMLProps, ReactNode, SelectHTMLAttributes } from "react"; + +export type SelectProps = Omit< + DetailedHTMLProps, HTMLSelectElement>, + "onFocus" | "onBlur" | "value" | "prefix" | "ref" +> & { + focused?: boolean; + onFocusedChange?: (focused: boolean) => void; + options: { + selectedDisplayValue?: ReactNode; + displayValue?: string; + value: string; + disabled?: boolean; + }[]; + value?: string; + prefix?: ReactNode; + suffix?: ReactNode; +}; diff --git a/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.scss b/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.scss new file mode 100644 index 0000000..3e0417a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.scss @@ -0,0 +1,126 @@ +.TextOrNumberInput { + display: flex; +} + +.TextOrNumberInput__Range { + margin-right: 10px; + + /* Based on the below – modified */ + /* Styling Cross-Browser Compatible Range Inputs with Sass */ + /* Github: https://github.com/darlanrod/input-range-sass */ + /* Author: Darlan Rod https://github.com/darlanrod */ + /* Version 1.5.2 */ + /* MIT License */ + + $track-color: #34353a !default; + $thumb-color: var(--theme-blue) !default; + + $thumb-radius: 12px !default; + $thumb-height: 14px !default; + $thumb-width: 14px !default; + + $track-width: 100px !default; + $track-height: 8px !default; + + $track-radius: 5px !default; + $contrast: 5% !default; + + $ie-bottom-track-color: darken($track-color, $contrast) !default; + + @mixin track { + cursor: default; + height: $track-height; + transition: all 0.2s ease; + width: $track-width; + } + + @mixin thumb { + background: $thumb-color; + border-radius: $thumb-radius; + box-sizing: border-box; + cursor: default; + height: $thumb-height; + width: $thumb-width; + } + + -webkit-appearance: none; + background: transparent; + width: $track-width; + + &::-moz-focus-outer { + border: 0; + } + + &:focus { + outline: 0; + + &::-webkit-slider-runnable-track { + background: lighten($track-color, $contrast); + } + + &::-ms-fill-lower { + background: $track-color; + } + + &::-ms-fill-upper { + background: lighten($track-color, $contrast); + } + } + + &::-webkit-slider-runnable-track { + @include track; + background: $track-color; + border-radius: $track-radius; + } + + &::-webkit-slider-thumb { + @include thumb; + -webkit-appearance: none; + margin-top: (($track-height) / 2 - $thumb-height / 2); + } + + &::-moz-range-track { + @include track; + background: $track-color; + border-radius: $track-radius; + height: $track-height / 2; + } + + &::-moz-range-thumb { + @include thumb; + } + + &::-ms-track { + @include track; + background: transparent; + border-color: transparent; + border-width: ($thumb-height / 2) 0; + color: transparent; + } + + &::-ms-fill-lower { + background: $ie-bottom-track-color; + border-radius: ($track-radius * 2); + } + + &::-ms-fill-upper { + background: $track-color; + border-radius: ($track-radius * 2); + } + + &::-ms-thumb { + @include thumb; + margin-top: $track-height / 4; + } + + &:disabled { + &::-webkit-slider-thumb, + &::-moz-range-thumb, + &::-ms-thumb, + &::-webkit-slider-runnable-track, + &::-ms-fill-lower, + &::-ms-fill-upper { + cursor: not-allowed; + } + } +} diff --git a/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.tsx b/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.tsx new file mode 100644 index 0000000..8d6647b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/TextOrNumberInput.tsx @@ -0,0 +1,42 @@ +import React, { FC } from "react"; + +import { RoundedTextInput } from "../RoundedTextInput"; + +import "./TextOrNumberInput.scss"; + +export const TextOrNumberInput: FC<{ + name?: string; + value: string | number; + onChange: (value: string | number) => void; + min: number | undefined; + max: number | undefined; + step: number | undefined; + type?: "string" | "number" | undefined | null; +}> = ({ name, value, onChange, min, max, step, type }) => { + const fieldType = type ?? typeof value; + + return ( +
    + {fieldType === "number" && min !== undefined && max !== undefined ? ( + onChange(evt.target.value)} + className="TextOrNumberInput__Range" + /> + ) : null} + onChange(evt.target.value)} + {...(fieldType === "number" ? { min, max, step } : {})} + /> +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/index.ts b/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/index.ts new file mode 100644 index 0000000..d2cb7fc --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Inputs/TextOrNumberInput/index.ts @@ -0,0 +1 @@ +export { TextOrNumberInput } from "./TextOrNumberInput"; diff --git a/apps/sim-core/packages/core/src/components/Inputs/index.ts b/apps/sim-core/packages/core/src/components/Inputs/index.ts new file mode 100644 index 0000000..fd02315 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Inputs/index.ts @@ -0,0 +1,4 @@ +export { TextOrNumberInput } from "./TextOrNumberInput"; +export { EnumInput } from "./EnumInput"; +export { RoundedTextInput } from "./RoundedTextInput"; +export type { RoundedTextInputProps } from "./RoundedTextInput"; diff --git a/apps/sim-core/packages/core/src/components/KeepInView/KeepInView.tsx b/apps/sim-core/packages/core/src/components/KeepInView/KeepInView.tsx new file mode 100644 index 0000000..237fd45 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/KeepInView/KeepInView.tsx @@ -0,0 +1,101 @@ +import React, { + createContext, + FC, + HTMLAttributes, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; + +import { useResizeObserver } from "../../hooks/useResizeObserver/useResizeObserver"; + +type Unsubscribe = VoidFunction; +type Subscribe = (handler: VoidFunction) => Unsubscribe; + +const KeepInViewContext = createContext(null); + +export const KeepInViewProvider: FC> = ({ + children, + ...props +}) => { + const subscribersRef = useRef([] as VoidFunction[]); + const observerRef = useResizeObserver(() => { + for (const handler of subscribersRef.current) { + handler(); + } + }); + + const subscribe = useCallback((handler) => { + subscribersRef.current.push(handler); + + return () => { + subscribersRef.current.splice(subscribersRef.current.indexOf(handler), 1); + }; + }, []); + + return ( + +
    + {children} +
    +
    + ); +}; + +export const useKeepInView = () => { + const parentRef = useRef(null); + const childRef = useRef(null); + const subscribe = useContext(KeepInViewContext); + + if (!subscribe) { + throw new Error("Cannot call useKeepInView outside of KeepInViewProvider"); + } + + const scroll = useCallback(() => { + if (childRef.current && parentRef.current) { + parentRef.current.scrollTo( + childRef.current.offsetLeft - parentRef.current.offsetLeft, + childRef.current.offsetTop - parentRef.current.offsetTop + ); + } + }, []); + + useEffect(() => { + scroll(); + }); + + useEffect(() => { + const unsubscribe = subscribe(scroll); + + return () => { + unsubscribe(); + }; + }, [subscribe, scroll]); + + const setParentRef = useCallback( + (node: HTMLElement | null) => { + if (node === parentRef.current) { + return; + } + + parentRef.current = node; + scroll(); + }, + [scroll] + ); + + const setChildRef = useCallback( + (node: HTMLElement | null) => { + if (node === childRef.current) { + return; + } + + childRef.current = node; + scroll(); + }, + [scroll] + ); + + return [setParentRef, setChildRef]; +}; diff --git a/apps/sim-core/packages/core/src/components/KeepInView/index.ts b/apps/sim-core/packages/core/src/components/KeepInView/index.ts new file mode 100644 index 0000000..5b1048d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/KeepInView/index.ts @@ -0,0 +1 @@ +export { KeepInViewProvider, useKeepInView } from "./KeepInView"; diff --git a/apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.css b/apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.css new file mode 100644 index 0000000..a807f9e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.css @@ -0,0 +1,29 @@ +.LabeledInputRadio-input { + background: var(--theme-darkest); + display: none; +} + +/** + * TODO: @mysterycommand - figure out how to DRY this up + * @see: ../HashCoreHeader/HashCoreHeader.css ~ L23 + */ +.LabeledInputRadio-label { + /* padding-vertical + (font-size * line-height) adds up to 32px */ + padding: 10px 0.5rem 9px; + font-size: 13px; + line-height: 1; + transition: background 0.1s; + cursor: pointer; + display: block; + user-select: none; +} + +.LabeledInputRadio-label--disabled { + pointer-events: none; + color: rgba(255, 255, 255, 0.7); +} + +.LabeledInputRadio-input:checked + .LabeledInputRadio-label, +.LabeledInputRadio-label:hover { + background: var(--theme-dark); +} diff --git a/apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.tsx b/apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.tsx new file mode 100644 index 0000000..2a62675 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/LabeledInputRadio/LabeledInputRadio.tsx @@ -0,0 +1,47 @@ +import React, { FC, MouseEvent } from "react"; +import classNames from "classnames"; + +import "./LabeledInputRadio.css"; + +type LabeledInputRadioProps = { + label: string; + group: string; + isChecked: (htmlFor: string) => boolean; + onClick?: (event: MouseEvent) => void; + onMouseEnter?: (event: MouseEvent) => void; + disabled?: boolean; +}; + +export const LabeledInputRadio: FC = ({ + label, + group, + isChecked, + onClick, + onMouseEnter, + disabled = false, +}) => { + const htmlFor = `${group}::${label}`; + + return ( + <> + + + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/LabeledInputRadio/index.ts b/apps/sim-core/packages/core/src/components/LabeledInputRadio/index.ts new file mode 100644 index 0000000..820b87e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/LabeledInputRadio/index.ts @@ -0,0 +1 @@ +export { LabeledInputRadio } from "./LabeledInputRadio"; diff --git a/apps/sim-core/packages/core/src/components/Link/Link.tsx b/apps/sim-core/packages/core/src/components/Link/Link.tsx new file mode 100644 index 0000000..14dbcac --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Link/Link.tsx @@ -0,0 +1,93 @@ +import React, { FC, forwardRef, HTMLProps } from "react"; +import { navigate } from "hookrouter"; + +import { Scope, useScope } from "../../features/scopes"; + +export type LinkProps = Omit< + HTMLProps, + "href" | "size" | "scope" | "ref" +> & { + path?: string; + query?: Record; + replace?: boolean; + scope?: Scope | null; + forceLogin?: boolean; +}; + +const getHref = (route: string | undefined, query: Record) => + `${route ?? "#"}${ + Object.keys(query).length > 0 + ? `?${new URLSearchParams(query).toString()}` + : "" + }`; + +export const Link: FC = forwardRef( + function Link( + { + path, + onClick, + query = {}, + children, + replace = false, + scope = null, + forceLogin, + target, + ...props + }, + ref + ) { + /** + * defaulting to Scope.login because we cannot dynamically call this hook. + * We'll only use the result of this if scope is passed in. + */ + const hasScope = useScope(scope ?? Scope.login); + + const absolute = path?.startsWith("http"); + + if (scope !== null && absolute) { + throw new Error("Cannot scope absolute URL"); + } + + let filteredQuery = Object.fromEntries( + Object.entries(query).filter( + ([_, value]) => value !== null && typeof value !== "undefined" + ) + ); + + let route = path; + let mappedOnClick = onClick; + + if ((scope !== null && !hasScope) || forceLogin) { + filteredQuery = path ? { route: getHref(path, filteredQuery) } : {}; + route = "/signin"; + mappedOnClick = undefined; + } + + const href = getHref(route, filteredQuery); + + return ( + { + if (!(evt.metaKey || evt.ctrlKey || evt.altKey)) { + evt.preventDefault(); + if (route) { + navigate(route, replace, filteredQuery); + } + } + + mappedOnClick?.(evt); + } + } + {...props} + > + {children} + + ); + } +); diff --git a/apps/sim-core/packages/core/src/components/Link/LinkBehavior.tsx b/apps/sim-core/packages/core/src/components/Link/LinkBehavior.tsx new file mode 100644 index 0000000..3463e45 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Link/LinkBehavior.tsx @@ -0,0 +1,18 @@ +import React, { FC } from "react"; + +import { HcSharedBehaviorFile } from "../../features/files/types"; +import { Link, LinkProps } from "./Link"; +import { mainProjectPath } from "../../routes"; +import { mapFileId } from "../../features/files/utils"; + +export const LinkBehavior: FC< + Omit & { file: HcSharedBehaviorFile } +> = ({ children, file, ...props }) => ( + + {children} + +); diff --git a/apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.css b/apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.css new file mode 100644 index 0000000..aa5b4c6 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.css @@ -0,0 +1,82 @@ +.loading-icon > .hash-logo { + width: 5rem; + height: 5rem; + position: relative; + animation: spin infinite 4s; + animation-timing-function: cubic-bezier(0.7, 0.1, 0.3, 0.9); +} + +.loading-icon.full-screen { + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.hash-logo > .vertical1 { + opacity: 0.9; + width: 28%; + height: 100%; + position: relative; + left: 16%; + background: linear-gradient(#00b8ff, #0010ff); +} + +.hash-logo > .vertical2 { + opacity: 0.89; + width: 28%; + height: 100%; + position: relative; + left: 56%; + top: -100%; + background: linear-gradient(#0046ff, #00bbff); +} + +.hash-logo > .horizontal1 { + opacity: 0.88; + width: 100%; + height: 28%; + position: relative; + top: -184%; + background: linear-gradient(90deg, #00afff, #5424ff); +} + +.hash-logo > .horizontal2 { + opacity: 0.86; + width: 100%; + height: 28%; + position: relative; + top: -172%; + background: linear-gradient(90deg, #6d2bf6, #0080ff); +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 20% { + transform: rotate(90deg); + } + 25% { + transform: rotate(90deg); + } + 45% { + transform: rotate(180deg); + } + 50% { + transform: rotate(180deg); + } + 70% { + transform: rotate(270deg); + } + 75% { + transform: rotate(270deg); + } + 95% { + transform: rotate(360deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.tsx b/apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.tsx new file mode 100644 index 0000000..566f213 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/LoadingIcon/LoadingIcon.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import "./LoadingIcon.css"; + +type LoadingIconProps = { + fullScreen?: boolean; +}; + +export const LoadingIcon: React.FC = ({ fullScreen }) => ( +
    +
    +
    +
    +
    +
    +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/LoadingIcon/index.ts b/apps/sim-core/packages/core/src/components/LoadingIcon/index.ts new file mode 100644 index 0000000..044db17 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/LoadingIcon/index.ts @@ -0,0 +1 @@ +export { LoadingIcon } from "./LoadingIcon"; diff --git a/apps/sim-core/packages/core/src/components/Logo/Logo.css b/apps/sim-core/packages/core/src/components/Logo/Logo.css new file mode 100644 index 0000000..c8ee935 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Logo/Logo.css @@ -0,0 +1,52 @@ +.logo { + display: inline-flex; + align-items: center; +} + +.hash-logo .vertical1 { + background: linear-gradient(-180deg, #00b8ff, #0010ff, #00b8ff, #0010ff); + opacity: 0.8; + background-size: 100% 300%; + width: 28%; + height: 100%; + position: relative; + left: 16%; +} + +.hash-logo .vertical2 { + background: linear-gradient(0deg, #00bbff, #0046ff, #00bbff, #0046ff); + opacity: 0.8; + background-size: 100% 300%; + width: 28%; + height: 100%; + position: relative; + left: 56%; + top: -100%; +} + +.hash-logo .horizontal1 { + background: linear-gradient(to right, #2a5eff, #2a42ff, #2a5eff, #2a42ff); + opacity: 0.8; + background-size: 300% 100%; + width: 100%; + height: 28%; + position: relative; + top: -184%; +} + +.hash-logo .horizontal2 { + background: linear-gradient(to right, #492cff, #492cff, #492cff, #492cff); + opacity: 0.8; + background-size: 300% 100%; + width: 100%; + height: 28%; + position: relative; + top: -172%; +} + +.hash-letters div, +.hash-letters svg { + height: 100%; + width: unset; + vertical-align: top; +} diff --git a/apps/sim-core/packages/core/src/components/Logo/Logo.spec.tsx b/apps/sim-core/packages/core/src/components/Logo/Logo.spec.tsx new file mode 100644 index 0000000..b2d3e49 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Logo/Logo.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { Logo } from "./Logo"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Logo/Logo.tsx b/apps/sim-core/packages/core/src/components/Logo/Logo.tsx new file mode 100644 index 0000000..ad16c78 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Logo/Logo.tsx @@ -0,0 +1,50 @@ +import React, { FC } from "react"; +import { ReactSVG } from "react-svg"; +import classNames from "classnames"; + +import "./Logo.css"; + +type LogoProps = { + size?: number; + logoSize?: number; + textSize?: number; + className?: string; +}; + +export const Logo: FC = ({ + size = 1, + logoSize = size, + textSize = size, + className, + children, +}) => ( +
    +
    +
    +
    +
    +
    +
    +
    + + {children} +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/Logo/index.ts b/apps/sim-core/packages/core/src/components/Logo/index.ts new file mode 100644 index 0000000..cd9b833 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Logo/index.ts @@ -0,0 +1 @@ +export { Logo } from "./Logo"; diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.scss b/apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.scss new file mode 100644 index 0000000..89901a5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.scss @@ -0,0 +1,191 @@ +.AnalysisModal-Container { + --grid-border: 1px solid #202122; + + background: transparent; + min-width: 750px; + width: 100%; +} + +.AnalysisModal { + background: rgba(10, 10, 10, 1); + padding: 3rem 0 0 0; + position: relative; + fill: white; + font-size: 14px; + border-radius: var(--modal-border-radius); + border: var(--modal-border); + + span { + user-select: none; + } + + .ModalFormEntry_Input > input, + .react-select__control { + border: var(--input-border) !important; + height: 38px !important; + box-sizing: border-box; + min-height: 38px !important; + + // @todo don't duplicate this + --field-background-color-default: rgba(255, 255, 255, 0.1); + background-color: var( + --field-background-color, + var(--field-background-color-default) + ) !important; + } + + .react-select__control svg { + fill: white; + } + + input:disabled { + opacity: 0.5; + cursor: not-allowed !important; + } +} + +.AnalysisModal .dropdown-wrapper { + cursor: pointer; +} + +.AnalysisModal__Container { + padding: 0 3rem 0 3rem; +} + +.AnalysisModal__TitleInputContainer { + width: 62% !important; +} + +/** + * @todo reduce duplication with ModalExit + */ +.AnalysisModal__GetHelp { + display: flex; + position: absolute; + top: 25px; + right: 75px; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + fill: rgba(255, 255, 255, 0.5); + align-items: center; + cursor: pointer; + text-decoration: none; + + .Icon { + margin-left: 0.5rem; + } + + &:hover { + color: white; + fill: white; + } +} + +.AnalysisModal__RepeatableContainer { + border: var(--grid-border); + border-radius: 16px; + margin-bottom: 2rem; + padding-bottom: 1rem; +} + +.AnalysisModal__RepeatableHeaderItem { + background: #000; + padding: 1rem 0 1rem 0rem; + max-height: 1rem; + font-weight: bold; + border-bottom: var(--grid-border); + font-size: 12px; +} + +.AnalysisModal__RepeatableHeaderItem:first-child { + border-top-left-radius: 16px; + padding-left: 2rem; +} + +.AnalysisModal__RepeatableHeaderItem:last-of-type { + border-top-right-radius: 16px !important; +} + +.AnalysisModal__RepeatableContentItem { + display: flex; + align-items: center; + justify-items: center; + padding: 0.5rem 0 0.5rem 0; + + > label { + width: 100%; + } +} + +.AnalysisModal__RepeatableContentItem--type { + margin-right: 2rem; + padding-left: 2rem; +} + +.AnalysisModal__RepeatableContentItem__Delete { + display: flex; + justify-content: center; + padding-top: 1rem; + + > .Icon { + cursor: pointer; + } +} + +.AnalysisModal__RepeatableFooterItem { + white-space: nowrap; + padding: 1rem 0 1.5rem 0; + cursor: pointer; + display: flex; + align-items: center; + padding-left: 2rem; + + > span { + margin-right: 0.5rem; + } +} + +.AnalysisModal__Footer { + display: flex; + align-items: center; + justify-content: space-between; + border-top: var(--modal-border); + padding: 2.5rem 3rem 2.5rem 3rem; + background: rgba(0, 0, 0, 0.9); + border-radius: 0 0 var(--modal-border-radius) var(--modal-border-radius); +} + +.AnalysisModal__Footer__Button { + background: #2482ff; + padding: 0.6rem 1rem 0.6rem 1rem; + border: 0; + border-radius: 7px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; +} + +.AnalysisModal__Footer__Button.delete { + color: var(--theme-red); + background: var(--theme-dark); + fill: white; + margin-left: 1rem; +} + +.AnalysisModal__Footer__Button.delete:hover { + color: white; + background: var(--theme-red); +} + +.AnalysisModal__ErrorNotification { + margin-bottom: 2rem; + background: var(--theme-red-alt); + padding: 0.8rem 1.2rem 0.8rem 1.2rem; + border-radius: 7px; + display: flex; + align-items: center; + + .IconAlertOutline { + margin-right: 0.5rem; + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.tsx new file mode 100644 index 0000000..0ede240 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/AnalysisModal.tsx @@ -0,0 +1,57 @@ +import React, { FC, FormEventHandler, ReactNode } from "react"; +import classNames from "classnames"; + +import { IconHelpCircleOutline } from "../../Icon/HelpCircleOutline"; +import { Modal } from "../Modal"; +import { ModalExit } from "../ModalExit"; + +import "./AnalysisModal.scss"; + +type AnalysisModalProps = { + onClose?: () => void; + cancelButton?: boolean; + className?: string; + title: string; + footerLegend: ReactNode | string | null; + submitButtonText: string; + onSubmit: FormEventHandler; +}; + +export const AnalysisModal: FC = ({ + onClose, + cancelButton = true, + children, + className, + title, + footerLegend, + submitButtonText, + onSubmit, +}) => ( + + {title ?

    {title}

    : null} +
    + {onClose && cancelButton ? : null} + + GET HELP + + +
    + {children} +
    + {footerLegend} + +
    +
    +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.scss b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.scss new file mode 100644 index 0000000..963d566 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.scss @@ -0,0 +1,65 @@ +.ModalOutputMetrics .ModalFormEntry__Label { + margin-bottom: 1rem; +} + +.ModalOutputMetrics__Operations { + display: grid; + grid-auto-flow: row; + grid-template-columns: 2fr 1fr 1fr 1fr 0.5fr; +} + +.ModalFormEntry_Input > input { + .AnalysisModal__RepeatableContentItem--metric & { + border-radius: 8px 0 0 8px; + } + + .AnalysisModal__RepeatableContentItem--metric:not(.AnalysisModal__RepeatableContentItem--single) + & { + border-right: none !important; + } + + .AnalysisModal__RepeatableContentItem--single & { + border-radius: 8px; + } + + .AnalysisModal__RepeatableContentItem--value & { + border-radius: 0 8px 8px 0; + border-left: 0; + } +} + +.AnalysisModal__RepeatableContentItem--single, +.AnalysisModal__RepeatableContentItem--metric, +.AnalysisModal__RepeatableContentItem--value { + --field-background-color: black; +} + +.AnalysisModal__RepeatableContentItem--comparison { + --field-background-color: rgba(0, 0, 0, 0.1); +} + +.react-select__control { + .AnalysisModal__RepeatableContentItem--comparison & { + border-radius: 0 !important; + border-right: none !important; + } +} + +.AnalysisModal__RepeatableContentItem__FieldDropdown .react-select__control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-right: -1px; +} + +.AnalysisModal__RepeatableFooterItem { + white-space: nowrap; + cursor: pointer; + display: flex; + align-items: center; + padding: 1rem 0 1.5rem 2rem; + + span { + margin-right: 0.5rem; + padding-left: 0.2rem; + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.spec.tsx new file mode 100644 index 0000000..61774c4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.spec.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { ModalProvider } from "react-modal-hook"; +import { render, fireEvent } from "@testing-library/react"; + +import { ErrorBoundary } from "../../ErrorBoundary"; +import { ModalOutputMetrics } from "./ModalOutputMetrics"; +import { mockProject } from "../../../features/project/mocks"; +import { setProjectWithMeta } from "../../../features/actions"; +import { store } from "../../../features/store"; + +const noop = () => {}; + +it("renders without crashing", () => { + const div = document.createElement("div"); + + store.dispatch(setProjectWithMeta(mockProject)); + + ReactDOM.render( + + + + + + + , + div + ); + ReactDOM.unmountComponentAtNode(div); +}); + +it("renders the right title and headings (create)", () => { + const { getByText } = render( + + + + + + + + ); + expect(getByText("Define new metric")).toBeDefined(); // title + expect(getByText("METRIC NAME")).toBeDefined(); // first input label + expect(getByText("OPERATIONS")).toBeDefined(); // dynamic operations label + expect(getByText("GET HELP")).toBeDefined(); // help link + expect(getByText("Add additional operation")).toBeDefined(); // Finish? heading + expect(getByText("Finished?")).toBeDefined(); // Finish? heading + expect( + getByText("You'll be able to use your new metric in any plot.") + ).toBeDefined(); // Finish? span + expect(getByText("Create new metric")).toBeDefined(); // submit button +}); + +it("renders the right title and headings (edit)", () => { + const { getByText } = render( + + + + + + + + ); + expect(getByText("Edit metric")).toBeDefined(); // title + expect(getByText("METRIC NAME")).toBeDefined(); // first input label + expect(getByText("OPERATIONS")).toBeDefined(); // dynamic operations label + expect(getByText("GET HELP")).toBeDefined(); // help link + expect(getByText("Add additional operation")).toBeDefined(); // Finish? heading + expect(getByText("Don't want this metric anymore?")).toBeDefined(); // Finish? heading + expect(getByText("Delete it")).toBeDefined(); // button + expect(getByText("Save changes")).toBeDefined(); // submit button +}); + +it("calls onClose when pressing ESCAPE key", () => { + const mockFn = jest.fn(); + const { baseElement } = render( + + + + + + + + ); + fireEvent.keyDown(baseElement, { key: "Escape", code: "Escape" }); + expect(mockFn).toHaveBeenCalled(); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.tsx new file mode 100644 index 0000000..55baaf4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalOutputMetrics.tsx @@ -0,0 +1,269 @@ +import React, { FC, useState } from "react"; +import { shallowEqual, useSelector } from "react-redux"; +import { useForm } from "react-hook-form"; + +import { AnalysisModal } from "./AnalysisModal"; +import { IconAlertOutline, IconPlus } from "../../Icon"; +import { ModalFormEntryRequiredText } from "../FormEntry"; +import { Operation, OperationTypes } from "../../Analysis/types"; +import { OperationItem } from "./OperationItem"; +import { OutputOperation } from "../../../features/analysis/analysisJsonTypes"; +import { RESERVED_BUILT_IN_KEYS } from "../../../features/files/validate"; +import { ReactSelectOption } from "../../Dropdown/types"; +import { selectLocalBehaviorKeyFieldNames } from "../../../features/files/selectors"; +import { useSafeOnClose } from "../../../hooks/useSafeOnClose"; +import { validateOutput } from "../../../features/analysis/analysisJsonValidation"; +import { validateTitle } from "../../../features/analysis/validation"; + +import "./ModalOutputMetrics.scss"; + +type ModalOutputMetricsProps = { + onClose: VoidFunction; + onSave: Function; + onDelete?: Function; + existingMetricKeys?: string[]; + metricKey?: string; + operations?: Operation[]; + isCreate?: boolean; +}; + +type FormInputs = { + title: string; + operations: Operation[]; +}; + +export const defaultNewOperation: Operation = { + op: OperationTypes.get, + field: "agent_id", +}; + +const operationTypesOptions: ReactSelectOption[] = [ + OperationTypes.filter, + OperationTypes.count, + OperationTypes.get, + OperationTypes.sum, + OperationTypes.min, + OperationTypes.max, + OperationTypes.mean, +].map((item) => ({ + value: item, + label: item, +})); + +export const ModalOutputMetrics: FC = ({ + onClose, + onSave, + onDelete = () => {}, + existingMetricKeys = [], + metricKey = "MyMetric", + isCreate = false, + operations = [defaultNewOperation], +}) => { + const [currentOperations, setCurrentOperations] = useState(operations); + const [isFormDirty, setIsFormDirty] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + } = useForm({ + defaultValues: { + title: metricKey, + operations, + }, + shouldFocusError: true, + mode: "onTouched", + }); + + const validate = () => { + const validationResults = validateOutput( + currentOperations as OutputOperation[] + ); + if ( + validationResults instanceof Error || + Array.isArray(validationResults) + ) { + setValidationErrors( + Array.isArray(validationResults) + ? validationResults + : [validationResults] + ); + return false; + } + + return true; + }; + + const onSubmit = async (values: FormInputs) => { + const result = { + ...values, + operations: currentOperations, + }; + if ( + existingMetricKeys.includes(result.title) && + (metricKey !== result.title || isCreate) + ) { + // this means that we are either creating a new one or renaming an existing + // one using a name that its already in use. This should fail the validation + setError("title", { + type: "manual", + message: + "The metric name you selected already exists. Please choose a different one", + }); + return; + } + if (!validate()) { + return; + } + onSave(result, metricKey); + onClose(); + }; + + const addNewOperationItem = () => { + setIsFormDirty(true); + setCurrentOperations([...currentOperations, defaultNewOperation]); + }; + const deleteOperationItem = (index: number) => { + setIsFormDirty(true); + setCurrentOperations( + currentOperations.filter((_val: Operation, idx: number) => idx !== index) + ); + }; + const updateOperationItem = (index: number, newValues: Operation) => { + setIsFormDirty(true); + const newOps = [...currentOperations]; + newOps[index] = newValues; + setCurrentOperations(newOps); + }; + + const AddNewOperation = () => ( + <> +
    + Add additional operation + +
    +
    +
    +
    +
    + + ); + + const safeOnClose = useSafeOnClose(!isFormDirty, true, onClose); + const localBehaviorKeys = useSelector( + selectLocalBehaviorKeyFieldNames, + shallowEqual + ); + const behaviorKeys = [...localBehaviorKeys, ...RESERVED_BUILT_IN_KEYS].sort(); + const behaviorKeysOptions: ReactSelectOption[] = behaviorKeys.map((key) => ({ + label: key, + value: key, + })); + // TODO: we simplified this function to get on time with the release, + // but leaving it here because we will update it after the board meeting (2021-01-21) + const getPermittedOperations = (): ReactSelectOption[] => + operationTypesOptions; + + const footerLegend = isCreate ? ( + <> + Finished? You'll be able to use your new metric in any + plot. + + ) : ( + <> + Don't want this metric anymore? + + { + evt.preventDefault(); + onDelete(metricKey); + onClose(); + }} + > + Delete it + + + ); + + const modalTitle = isCreate ? "Define new metric" : "Edit metric"; + const submitButtonText = isCreate ? "Create new metric" : "Save changes"; + + return ( + +
    + {validationErrors.length === 1 && ( +

    There was an error while trying to save your metric

    + )} + {validationErrors.length > 1 && ( +

    There were some errors while trying to save your metric

    + )} + {validationErrors.map((error) => ( +
    + +

    {error.message}

    +
    + ))} + + + +
    + OPERATIONS +
    + +
    + + TYPE + + + FIELD + + + COMPARISON + + + VALUE + + + + {currentOperations.length > 0 && + currentOperations.map((operation: Operation, index: number) => ( + deleteOperationItem(index)} + onChange={updateOperationItem} + permittedOperations={getPermittedOperations()} + behaviorKeysOptions={behaviorKeysOptions} + hideDelete={index === 0 && currentOperations.length === 1} + /> + ))} + {currentOperations[currentOperations.length - 1].op !== + OperationTypes.count && } +
    +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.scss b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.scss new file mode 100644 index 0000000..b72495d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.scss @@ -0,0 +1,59 @@ +.ModalPlots { + width: 60%; + min-width: 728px; + background: rgba(0, 0, 0, 0.75); + padding-top: 3rem; + padding: 3rem 0 0 0; +} + +.ModalPlots__Container { + padding: 0 3rem 0 3rem; + overflow-y: auto; + max-height: 60vh; +} + +.ModalPlots span { + user-select: none; +} + +.ModalPlots .ModalFormEntry__Label { + margin-bottom: 1rem; +} + +.ModalPlots__Container .ModalFormEntry__Description { + margin: 0.25rem 0 1rem 0; + font-weight: normal; + text-transform: none; +} + +.ModalPlots .dropdown-wrapper { + cursor: pointer; +} + +.ModalPlots__Title { + position: absolute; + top: -13%; +} + +.ModalPlots__YAxisItems { + display: grid; + grid-auto-flow: row; + grid-template-columns: 1fr 1fr 0.2fr; +} + +.ModalPlots__LegendDisplayContainer { + display: flex; + margin-bottom: 1.25rem; + + .ModalPlots__LegendDisplayLabel { + display: flex; + margin-right: 1rem; + + .CheckboxInput { + margin-right: 0.55rem; + background: var( + --theme-dark-border + ); // TODO: discuss if we should add a new color for checkboxes on dark models + } + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.spec.tsx new file mode 100644 index 0000000..ed4ef83 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.spec.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; + +import { ErrorBoundary } from "../../ErrorBoundary"; +import { ModalPlots } from "./ModalPlots"; +import { mockProject } from "../../../features/project/mocks"; +import { setProjectWithMeta } from "../../../features/actions"; +import { store } from "../../../features/store"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + + store.dispatch(setProjectWithMeta(mockProject)); + + ReactDOM.render( + + + {}} + onSave={() => {}} + outputs={{ hello: [{ op: "get", field: "bla" }] }} + /> + + , + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.tsx new file mode 100644 index 0000000..dfb329d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/ModalPlots.tsx @@ -0,0 +1,511 @@ +import React, { FC, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { AnalysisModal } from "./AnalysisModal"; +import { ChartTypes, XAxisItemType, YAxisItemType } from "../../Analysis/types"; +import { CheckboxInput } from "../../Inputs/Checkbox/CheckboxInput"; +import { IconAlertOutline, IconPlus } from "../../Icon"; +import { + MAGIC_STEPS_KEY, + transformPlotDataBasedOnChartType, +} from "../../Analysis/modals"; +import { + ModalFormEntryDropdown, + ModalFormEntryRequiredText, +} from "../FormEntry"; +import { ModalFormEntryLabel } from "../FormEntry/ModalFormEntryLabel"; +import { ReactSelectOption } from "../../Dropdown/types"; +import { YAxisItem } from "./YAxisItem"; +import { useFatalError } from "../../ErrorBoundary/ErrorBoundary"; +import { validatePlot } from "../../../features/analysis/analysisJsonValidation"; + +import "./ModalPlots.scss"; + +type ModalPlotsProps = { + onClose: VoidFunction; + onSave: Function; + outputs: { [index: string]: any[] }; + onDelete?: Function; + plotKey?: number; + plotTitle?: string; + plotChartType?: string; + layout?: { + height?: number; + width?: number; + hideLegend?: boolean; + hideCollatedLegend?: boolean; + }; + YAxisItems?: YAxisItemType[]; + XAxisItems?: XAxisItemType[]; + isCreate?: boolean; + combinedHeightOfAllPlots?: number; +}; + +type FormInputs = { + title: string; + chartType: ChartTypes; + YAxisItems: YAxisItemType[]; + XAxisItems: XAxisItemType[]; +}; + +const chartTypeOptions: ReactSelectOption[] = [ + ChartTypes.area, + ChartTypes.bar, + ChartTypes.box, + ChartTypes.histogram, + ChartTypes.timeseries, + ChartTypes.line, + ChartTypes.scatter, +].map((item) => ({ + value: item, + label: item, +})); + +const DEFAULT_PLOT_HEIGHT = 50; + +const plotSupportsXAxis = (type: ChartTypes) => + ["histogram", "scatter", "line"].includes(type); +const plotSupportsYAxis = (type: ChartTypes) => + ["area", "box", "bar", "histogram", "timeseries", "scatter", "line"].includes( + type + ); + +const shouldShowXAxis = ( + chartType: ChartTypes, + _xAxisItems: XAxisItemType[] = [], + yAxisItems: XAxisItemType[] = [] +) => { + const plotSupportsIt = plotSupportsXAxis(chartType); + if (!plotSupportsIt) { + return false; + } + if (chartType === ChartTypes.histogram) { + // if we already have X, then we show that + return yAxisItems.length === 0; + } + return true; +}; + +const shouldShowYAxis = ( + chartType: ChartTypes, + xAxisItems: XAxisItemType[] = [], + _yAxisItems: YAxisItemType[] = [] +) => { + const plotSupportsIt = plotSupportsYAxis(chartType); + if (!plotSupportsIt) { + return false; + } + if (chartType === ChartTypes.histogram) { + return xAxisItems.length === 0; + } + return true; +}; + +export const ModalPlots: FC = ({ + onClose, + onSave, + outputs, + onDelete = () => {}, + plotKey = false, + plotTitle = "", + plotChartType = false, + layout = { + width: "100%", + height: `${DEFAULT_PLOT_HEIGHT}%`, + hideLegend: false, + hideCollatedLegend: false, + }, + isCreate = false, + YAxisItems = [], + XAxisItems = [], + combinedHeightOfAllPlots = 0, +}) => { + const fatalError = useFatalError(); + + const [currentYAxisItems, setCurrentYAxisItems] = useState( + YAxisItems + ); + const [currentXAxisItems, setCurrentXAxisItems] = useState( + XAxisItems + ); + const [chartType, setChartType] = useState( + isCreate ?? !plotChartType + ? chartTypeOptions[0] + : { label: String(plotChartType), value: String(plotChartType) } + ); + const [hideLegend, setHideLegend] = useState(!!layout.hideLegend); + const [hideCollatedLegend, setHideCollatedLegend] = useState( + !!layout.hideCollatedLegend + ); + const [validationErrors, setValidationErrors] = useState([]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + title: plotTitle, + }, + shouldFocusError: true, + mode: "onTouched", + }); + + const metricKeys = Object.keys(outputs); + + const prepareDataForValidation = (input: any) => + transformPlotDataBasedOnChartType({ + title: input.title, + type: input.chartType.value, + data: { yitems: input.yitems, xitems: input.xitems }, + layout: input.layout, + position: input.position, + }); + + const getFormState = (values: FormInputs) => ({ + ...values, // title + chartType, + yitems: currentYAxisItems, + xitems: currentXAxisItems, + layout: { + height: layout.height, + width: layout.width, + hideLegend: hideLegend, + hideCollatedLegend: hideCollatedLegend, + }, + position: { + x: "0%", + y: `${combinedHeightOfAllPlots}%`, + }, + }); + + const validate = (result: any) => { + const validationResults = validatePlot( + prepareDataForValidation(result), + outputs + ); + + if ( + validationResults instanceof Error || + Array.isArray(validationResults) + ) { + setValidationErrors( + !Array.isArray(validationResults) + ? [validationResults] + : validationResults + ); + return false; + } + return true; + }; + + const onSubmit = async (values: FormInputs) => { + const result = getFormState(values); + if (!validate(result)) { + return; + } + + try { + onSave(result, plotKey); + onClose(); + } catch (err) { + fatalError(err); + } + }; + + const addNewAxisItem = (axisToAddTo: "x" | "y") => { + const newInput = { + name: `${metricKeys[metricKeys.length - 1]}${currentXAxisItems.length}`, + metric: metricKeys[metricKeys.length - 1], + }; + if (axisToAddTo === "x") { + setCurrentXAxisItems([...currentXAxisItems, newInput]); + } else { + setCurrentYAxisItems([...currentYAxisItems, newInput]); + } + }; + + const deleteAxisItem = (axisToModify: "x" | "y", index: number) => { + if (axisToModify === "x") { + setCurrentXAxisItems( + currentXAxisItems.filter((_val, idx) => idx !== index) + ); + } else { + setCurrentYAxisItems( + currentYAxisItems.filter((_val, idx) => idx !== index) + ); + } + }; + + const updateAxisItem = ( + axisToUpdate: "x" | "y", + index: number, + newValues: YAxisItemType | XAxisItemType + ) => { + const items = axisToUpdate === "x" ? currentXAxisItems : currentYAxisItems; + const newOps = [...items]; + newOps[index] = newValues; + if (axisToUpdate === "x") { + setCurrentXAxisItems(newOps); + } else { + setCurrentYAxisItems(newOps); + } + }; + + const addNewXAxisItem = () => addNewAxisItem("x"); + const addNewYAxisItem = () => addNewAxisItem("y"); + const deleteXAxisItem = (index: number) => deleteAxisItem("x", index); + const deleteYAxisItem = (index: number) => deleteAxisItem("y", index); + const updateXAxisItem = (index: number, newValues: XAxisItemType) => + updateAxisItem("x", index, newValues); + const updateYAxisItem = (index: number, newValues: YAxisItemType) => + updateAxisItem("y", index, newValues); + + const AddNewYAxisItem = () => ( + <> +
    + Add Y Axis Item + +
    +
    +
    + + ); + + const AddNewXAxisItem = () => ( + <> +
    + Add X axis item + +
    +
    +
    + + ); + + const metricKeysOptions: ReactSelectOption[] = metricKeys.map((key) => ({ + value: key, + label: key, + })); + const metricKeysOptionsWithMagicSteps: ReactSelectOption[] = [ + ...metricKeysOptions, + { + value: MAGIC_STEPS_KEY, + label: "Steps", + }, + ]; + + const title = isCreate ? "Create new analysis plot" : "Edit plot"; + const submitButtonText = isCreate ? "Create new plot" : "Save changes"; + const footerLegend = isCreate ? ( + <> + Finished creating your plot? You can always edit it + later. + + ) : ( + <> + Don't want this plot anymore? + + { + evt.preventDefault(); + onDelete(plotKey); + onClose(); + }} + > + Delete it + + + ); + + const magicStepsXAxisItem = { metric: MAGIC_STEPS_KEY, name: "Steps" }; + + // TODO: clean this up + if ( + ["line", "scatter"].includes(chartType.value) && + !isCreate && + currentXAxisItems.length === 0 + ) { + setCurrentXAxisItems([magicStepsXAxisItem]); + } + + return ( + +
    + {validationErrors.length === 1 && ( +

    There was an error while trying to save your Plot

    + )} + {validationErrors.length > 1 && ( +

    There were some errors while trying to save your Plot

    + )} + {validationErrors.map((error) => ( +
    + +

    {error.message}

    +
    + ))} + { + setChartType(newValue); + if (["line", "scatter"].includes(newValue.value) && isCreate) { + setCurrentXAxisItems([magicStepsXAxisItem]); + } else { + setCurrentXAxisItems([]); + } + }} + /> + + + + Legend display +
    + + +
    + + {shouldShowXAxis( + chartType.value as ChartTypes, + currentXAxisItems, + currentYAxisItems + ) && ( + <> +
    + X AXIS +
    + {["line", "scatter"].includes(chartType.value) ? ( +
    + By default, the X axis will plot Steps. Change this by choosing + another metric. +
    + ) : null} + +
    + + LABEL + + + METRIC + + + + {currentXAxisItems.length > 0 && + currentXAxisItems.map((item, index) => ( + deleteXAxisItem(index)} + onChange={updateXAxisItem} + hideDelete={ + index === 0 && + currentXAxisItems.length === 1 && + chartType.value !== "histogram" && + chartType.value !== "scatter" && + chartType.value !== "line" + } + /> + ))} + + +
    + + )} + + {shouldShowYAxis( + chartType.value as ChartTypes, + currentXAxisItems, + currentYAxisItems + ) && ( + <> +
    + Y AXIS +
    + +
    + + LABEL + + + METRIC + + + + {currentYAxisItems.length > 0 && + currentYAxisItems.map((item, index) => ( + deleteYAxisItem(index)} + onChange={updateYAxisItem} + hideDelete={ + index === 0 && + currentXAxisItems.length === 1 && + chartType.value !== "histogram" && + chartType.value !== "scatter" && + chartType.value !== "line" + } + /> + ))} + + +
    + + )} +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/OperationItem.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/OperationItem.tsx new file mode 100644 index 0000000..75568a5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/OperationItem.tsx @@ -0,0 +1,166 @@ +import React, { FC } from "react"; +import classNames from "classnames"; + +import { + ComparisonTypes, + Operation, + OperationItemProps, + OperationTypes, +} from "../../Analysis/types"; +import { IconTrash } from "../../Icon/Trash"; +import { + ModalFormEntryDropdown, + ModalFormEntryRequiredText, +} from "../FormEntry"; +import { ReactSelectOption } from "../../Dropdown/types"; + +const comparisonOptions: ReactSelectOption[] = [ + ComparisonTypes.eq, + ComparisonTypes.neq, + ComparisonTypes.lt, + ComparisonTypes.lte, + ComparisonTypes.gt, + ComparisonTypes.gte, +].map((item) => ({ + value: item, + label: item, +})); + +const operationSupportsFieldField = (OpType: OperationTypes) => + OpType === OperationTypes.filter || OpType === OperationTypes.get; +const operationSupportsComparisonField = (OpType: OperationTypes) => + OpType === OperationTypes.filter; +const operationSupportsValueField = (OpType: OperationTypes) => + OpType === OperationTypes.filter; + +export const OperationItem: FC = ({ + operation, + index, + onDelete, + onChange, + permittedOperations, + hideDelete = false, + behaviorKeysOptions = [], +}) => { + const filterPropertiesBasedOnOperationType = ( + OpType: OperationTypes, + newValues: Operation + ) => { + if (operationSupportsFieldField(OpType) && !newValues.field) { + newValues.field = "agent_id"; + } + if (!operationSupportsFieldField(OpType)) { + delete newValues.field; + } + if (operationSupportsComparisonField(OpType) && !newValues.comparison) { + newValues.comparison = ComparisonTypes.eq; + } + if (!operationSupportsComparisonField(OpType)) { + delete newValues.comparison; + } + if (operationSupportsValueField(OpType) && !newValues.value) { + newValues.value = "50"; + } + if (!operationSupportsValueField(OpType)) { + delete newValues.value; + } + return newValues; + }; + + return !operation ? null : ( + <> +
    + { + const newValues = filterPropertiesBasedOnOperationType( + option.value, + { ...operation, op: option.value } + ); + onChange(index, newValues); + }} + /> +
    + +
    + {operationSupportsFieldField(operation.op) && ( + { + const newValues = { ...operation, field: selectedOption.value }; + onChange(index, newValues); + }} + /> + )} +
    +
    + {operationSupportsComparisonField(operation.op) && + operation.comparison && ( + { + const newValues = { ...operation, comparison: option.value }; + onChange(index, newValues); + }} + /> + )} +
    +
    + {operationSupportsValueField(operation.op) && ( + { + let val: boolean | number | string = ev.target.value; + if (!isNaN(parseInt(val, 10)) || !isNaN(parseFloat(val))) { + val = Number(val); + } + if (val === "true" || val === "True") { + val = true; + } + if (val === "false" || val === "False") { + val = false; + } + onChange(index, { ...operation, value: val }); + }} + /> + )} +
    +
    {}} + > + {!hideDelete && } +
    + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Analysis/YAxisItem.tsx b/apps/sim-core/packages/core/src/components/Modal/Analysis/YAxisItem.tsx new file mode 100644 index 0000000..890ecb2 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Analysis/YAxisItem.tsx @@ -0,0 +1,51 @@ +import React, { FC } from "react"; + +import { IconTrash } from "../../Icon/Trash"; +import { + ModalFormEntryDropdown, + ModalFormEntryRequiredText, +} from "../FormEntry"; +import { YAxisItemProps } from "../../Analysis/types"; + +export const YAxisItem: FC = ({ + item, + index, + metricKeysOptions, + onDelete, + onChange, + hideDelete = false, +}) => { + return !item ? null : ( + <> +
    + { + const newValues = { ...item, name: ev.target.value }; + onChange(index, newValues); + }} + value={item.name} + /> +
    +
    + { + onChange(index, { ...item, metric: option.value }); + }} + /> +
    +
    {}} + > + {!hideDelete && } +
    + + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/BigModal.css b/apps/sim-core/packages/core/src/components/Modal/BigModal.css new file mode 100644 index 0000000..01ae6ad --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/BigModal.css @@ -0,0 +1,16 @@ +.BigModal { + --big-modal-padding: 30px 45px; + box-sizing: border-box; + border-radius: 16px; + position: relative; + border: 1px solid rgba(27, 29, 36, 0.9); + padding: var(--big-modal-padding); + margin: auto; +} + +.BigModal-Container { + padding: 40px; + overflow: auto; + align-items: flex-start; + box-sizing: border-box; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/BigModal.tsx b/apps/sim-core/packages/core/src/components/Modal/BigModal.tsx new file mode 100644 index 0000000..9ffccc8 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/BigModal.tsx @@ -0,0 +1,32 @@ +import React, { FC } from "react"; +import classNames from "classnames"; + +import { Modal } from "./Modal"; +import { ModalExit } from "./ModalExit"; + +import "./BigModal.css"; + +type BigModalProps = { + onClose?: () => void; + cancelButton?: boolean; + className?: string; + backdropClassName?: string; +}; + +export const BigModal: FC = ({ + onClose, + cancelButton = true, + children, + className, + backdropClassName, +}) => ( + + {onClose && cancelButton && } + {children} + +); diff --git a/apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.css b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.css new file mode 100644 index 0000000..ff121bc --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.css @@ -0,0 +1,85 @@ +.ModalCloudUsage { + border: 1px solid var(--theme-border); + border-radius: 7px; + overflow: hidden; + width: 767px; + text-align: center; +} + +.ModalCloudUsage__Section { + padding: 45px; +} + +.ModalCloudUsage__Section:first-child { + border-bottom: 1px solid var(--theme-border); + background-color: #1b1d23; + padding-top: 50px; +} + +.ModalCloudUsage__Section:last-child { + background-color: #1f2127; + padding-bottom: 60px; +} + +.ModalCloudUsage h2, +.ModalCloudUsage h3, +.ModalCloudUsage p { + margin: 0; + padding: 0; +} + +.ModalCloudUsage p { + font-size: 16px; +} + +.ModalCloudUsage h2 { + font-size: 32px; + margin-bottom: 10px; +} + +.ModalCloudUsage h3 { + font-size: 21px; + margin-bottom: 25px; +} + +.ModalCloudUsage__Buttons { + display: flex; + width: fit-content; + margin: 0 auto; +} + +.ModalCloudUsage__Buttons button, +.ModalCloudUsage__Buttons a { + font-weight: bold; + color: white; + fill: white; + font-size: 13px; + text-transform: uppercase; + border: none; + padding: 0 10px 0 20px; + height: 45px; +} + +.ModalCloudUsage__Buttons .Icon { + margin-left: 5px; +} + +.ModalCloudUsage__Buttons__Simulate { + background: rgba(255, 255, 255, 0.1); +} + +.ModalCloudUsage__Buttons__Simulate:hover { + background: rgba(255, 255, 255, 0.2); +} + +.ModalCloudUsage__Buttons__Contact { + background: var(--theme-blue); +} + +.ModalCloudUsage__Buttons__Contact:hover { + background: var(--theme-blue-hover); +} + +.ModalCloudUsage__Buttons > *:not(:first-child) { + margin-left: 20px; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.tsx b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.tsx new file mode 100644 index 0000000..274cba3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/ModalCloudUsage.tsx @@ -0,0 +1,51 @@ +import React, { FC } from "react"; + +import { FancyAnchor } from "../../Fancy/Anchor"; +import { FancyButton } from "../../Fancy/Button"; +import { Modal } from "../Modal"; +import { toggleProviderTarget } from "../../../features/simulator/simulate/thunks"; +import { useSimulatorDispatch } from "../../../features/simulator/context"; + +import "./ModalCloudUsage.css"; + +export const ModalCloudUsage: FC<{ onCancel: VoidFunction }> = ({ + onCancel, +}) => { + const simulatorDispatch = useSimulatorDispatch(); + + return ( + +
    +

    You've reached your cloud compute limit

    +

    + As a free user, you get 10 hours of cloud compute + credit each month +

    +
    +
    +

    Upgrade to HASH Pro for unlimited cloud simulation runs

    +
    + { + evt.preventDefault(); + simulatorDispatch(toggleProviderTarget("web")); + onCancel(); + }} + > + Simulate Locally + + + Contact us about Pro + +
    +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/CloudUsage/index.ts b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/index.ts new file mode 100644 index 0000000..98fa24d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/index.ts @@ -0,0 +1,2 @@ +export { ModalCloudUsage } from "./ModalCloudUsage"; +export { useModalCloudUsage } from "./useModalCloudUsage"; diff --git a/apps/sim-core/packages/core/src/components/Modal/CloudUsage/useModalCloudUsage.tsx b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/useModalCloudUsage.tsx new file mode 100644 index 0000000..b3728ee --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/CloudUsage/useModalCloudUsage.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { useModal } from "react-modal-hook"; + +import { ModalCloudUsage } from "./ModalCloudUsage"; + +export const useModalCloudUsage = () => { + const [showModal, hideModal] = useModal( + () => , + [] + ); + return [showModal, hideModal]; +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.css b/apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.css new file mode 100644 index 0000000..a4357f5 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.css @@ -0,0 +1,31 @@ +.ModalConfirmFileDelete { + padding-right: 100px; +} + +.ModalConfirmFileDelete__title { + font-size: 30px; + line-height: 30px; + font-weight: normal; + color: var(--theme-grey); + margin-bottom: 20px; + margin-top: 0; +} + +.ModalConfirmFileDelete__detail { + font-size: 16px; + line-height: 16px; + margin-bottom: 30px; +} + +.ModalConfirmFileDelete__buttons { + display: flex; +} + +.ModalConfirmFileDelete__buttons > * { + margin-right: 20px; +} + +.ModalConfirmFileDelete__buttons__label { + margin-right: 5px; + font-size: 13px; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.tsx b/apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.tsx new file mode 100644 index 0000000..b269d92 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Confirm/FileDelete/ModalConfirmFileDelete.tsx @@ -0,0 +1,50 @@ +import React, { FC } from "react"; + +import { BigModal } from "../.."; +import { FancyButton } from "../../../Fancy"; +import { useModalConfirm } from "../hooks"; + +import "./ModalConfirmFileDelete.css"; + +type ModalConfirmFileDeleteProps = { + onAnswer: (answer: boolean) => void; + fileName: string; +}; + +export const ModalConfirmFileDelete: FC = ({ + onAnswer, + fileName, +}) => { + useModalConfirm(onAnswer); + + return ( + onAnswer(false)} + className="ModalConfirmFileDelete" + > +

    + Are you sure you want to delete {fileName}? +

    +

    + Removing {fileName} from your simulation cannot be + undone. +

    +
    + onAnswer(true)} icon="trash"> + + CONFIRM DELETION + + + onAnswer(false)} + icon="cancel" + theme="black" + > + + CANCEL + + +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Confirm/hooks.ts b/apps/sim-core/packages/core/src/components/Modal/Confirm/hooks.ts new file mode 100644 index 0000000..463fc82 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Confirm/hooks.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +export const useModalConfirm = (onAnswer: (confirm: boolean) => void) => { + useEffect(() => { + function handler(evt: KeyboardEvent) { + if (evt.key === "Enter") { + evt.preventDefault(); + onAnswer(true); + } + } + + window.addEventListener("keydown", handler); + + return () => { + window.removeEventListener("keydown", handler); + }; + }, [onAnswer]); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.scss b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.scss new file mode 100644 index 0000000..d7bd8e7 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.scss @@ -0,0 +1,196 @@ +.ExperimentModal { + // This must not be transparent due to the sticky header of the fields table + --experiments-background: rgb(17, 17, 17); + border-radius: var(--modal-border-radius); + border: 1px solid var(--theme-border); + background-color: var(--experiments-background); + + // @todo remove this hack - should be removed from Dropdown.scss + // But other layout might be relying on the higher min-height there + div.react-select__control { + min-height: 46px !important; + } + + .RoundedSelect { + background: rgba(255, 255, 255, 0.1); + border-radius: 7px; + height: 100%; + width: 100%; + } +} + +.ExperimentModal__Topbar { + position: relative; + width: 100%; + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 1rem 1rem 0 0; +} + +.ExperimentModal__Topbar .ModalExit { + position: relative; + top: 0; + right: 0; + margin-right: 1rem; +} + +.ExperimentModal__Content { + padding: 0rem 3.8rem 0px 3.8rem; +} + +.ExperimentModal__CreationHint { + margin-top: 0; + margin-bottom: 35px; +} + +.ExperimentModal__TypeDropdownContainer { + display: flex; + align-items: center; + width: 100%; + + .IconInformationOutline { + fill: white; + } + + .ExperimentModal__TypeDropdown_TooltipContainer { + margin-left: 10px; + padding: 2px; + } +} + +.ExperimentModal__TypeDropdown_Tooltip { + font-size: 13px; +} + +.ExperimentModal__TypeDropdownContainer > :first-child, +.ExperimentModal__TitleContainer > :first-child { + width: 50%; +} + +.ExperimentModal__TitleContainer { + margin-bottom: 1.5rem; +} + +.ExperimentModal__GetHelp { + display: flex; + align-items: center; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + fill: rgba(255, 255, 255, 0.5); + cursor: pointer; + text-decoration: none; + margin-right: 1rem; + + &:hover { + color: white; + fill: white; + font-weight: bold; + } +} + +.ExperimentModal__GetHelpIcon { + margin-left: 0.5rem; +} + +.ExperimentModal__DynamicFieldsContainer { + display: flex; + align-items: center; + justify-content: space-between; + + > label { + padding-right: 1rem; + margin-bottom: 1.5rem; + flex: 1; + } + + > label:last-child { + padding-right: 0; + } +} + +.ExperimentsModal__FieldsTableContainer { + margin-bottom: 0.66rem; + max-height: 134px; + overflow-y: auto; +} + +.ExperimentsModal__FieldsTable { + text-align: left; + width: 100%; + + .ExperimentsModal__FieldsRow { + display: flex; + + &:first-child { + position: sticky; + top: 0; + background-color: var(--experiments-background); + z-index: 1; + } + } + + .ExperimentsModal__FieldsCell { + &:not(.ExperimentsModal__FieldsCellDelete) { + width: 100%; + } + } + + .ExperimentsModal__FieldsCell small { + font-size: 0.8em; + opacity: 0.7; + } + + .ExperimentsModal__FieldsCell { + &:not(:last-child) { + padding-right: 1rem; + } + } + + .ExperimentsModal__FieldsRow { + &:not(:last-child) { + padding-bottom: 0.66rem; + } + } + + .ExperimentsModal__FieldsRow .ExperimentsModal__FieldsCell { + &:first-child { + min-width: 400px; + width: 50%; + } + } +} + +.ExperimentModal__AddField { + strong { + text-transform: none !important; + } +} + +.ExperimentModal__ErroredField { + border: 1px solid var(--theme-red) !important; + border-radius: 7px; +} + +.ExperimentModal__ActionButtonsContainer { + margin-top: 2rem; + padding-top: 2rem; + display: flex; + justify-content: flex-end; + align-items: center; + padding: 3rem; + background: var(--theme-black); + border: 0; + border-top: 1px solid var(--theme-border); + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: var(--modal-border-radius); + border-bottom-right-radius: var(--modal-border-radius); + + button { + border-radius: 7px; + font-weight: bold; + cursor: pointer; + margin-left: 1rem; + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.spec.tsx new file mode 100644 index 0000000..5c29030 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.spec.tsx @@ -0,0 +1,506 @@ +describe.skip("ExperimentModal tests", () => { + it.todo("Please FIXME. See this file for more info."); +}); + +export const thisMustBeHereToMakeTheBuildHappyAboutTheFactThatWeDoNotHaveAnImport = + "this must Be Here To Make The Build Happy About The Fact That We Do Not Have An Import"; +// import { Provider } from "react-redux"; +// import { ModalProvider } from "react-modal-hook"; +// import { screen, render, fireEvent } from "@testing-library/react"; +// import userEvent, { TargetElement } from "@testing-library/user-event"; + +// import { ExperimentModal } from "./ExperimentModal"; +// import { mockProject } from "../../../features/project/mocks"; +// import { setProjectWithMeta } from "../../../features/actions"; +// import { store } from "../../../features/store"; + +// TODO: Fix these tests. They are failing with the following error: +// ReferenceError: WEBPACK_PUBLIC_PATH is not defined +// import { getLocalStorageSimulatorTarget } from "./target"; +// > const workerUrl = urljoin(WEBPACK_PUBLIC_PATH, "simulationworker.js"); +// Most probably this is happening because the Jest pipeline is not running through webpack, +// or I am missing another layer of Providers + +// describe.skip("ExperimentModal tests", () => { +// it("renders without crashing", () => { +// const div = document.createElement("div"); +// ReactDOM.render( +// +// +// {}} /> +// +// , +// div +// ); +// ReactDOM.unmountComponentAtNode(div); +// }); + +// it("submitting should fail due to validation (title)", () => { +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const submitButton = screen.getByText("Create experiment"); +// fireEvent.click(submitButton); +// expect(mockFn).not.toHaveBeenCalled(); +// expect(screen.getByText("This field is required")).toBeDefined(); +// }); + +// it.todo("submitting should fail due to validation (duplicated title)"); + +// it("submitting should fail due to validation for values.field and values.values", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const titleInput: HTMLElement & { +// value?: string; +// } = screen.getByPlaceholderText("Experiment title"); +// expect(titleInput).toBeDefined(); +// userEvent.type(titleInput, "Wonderful title"); +// expect(titleInput.value).toBe("Wonderful title"); +// const submitButton = screen.getByText("Create experiment"); +// userEvent.click(submitButton); +// expect(mockFn).not.toHaveBeenCalled(); + +// const fieldValues = screen.getByPlaceholderText("Comma, separated, list"); + +// expect( +// fieldValues.classList.contains("ExperimentModal__ErroredField") +// ).toBeTruthy(); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// userEvent.type(fieldValues, "pizza, pasta, gelato"); +// expect( +// fieldValues.classList.contains("ExperimentModal__ErroredField") +// ).toBeFalsy(); +// }); + +// it("should create new experiment (type: values)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const fieldValues = screen.getByPlaceholderText("Comma, separated, list"); +// const title = "Wonderful title"; +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// userEvent.type(fieldValues, "pizza, pasta, gelato"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("values"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.values).toContain("pizza"); +// expect(contents?.[title]?.values).toContain(" pasta"); +// expect(contents?.[title]?.values).toContain(" gelato"); +// }); + +// it("should create new experiment (type: linspace)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const linspaceElement = screen.getByText("Linspace sweeping"); +// userEvent.click(linspaceElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Linspace Sweeping +// const startInput = screen.getByTestId("input-linspace-start"); +// const stopInput = screen.getByTestId("input-linspace-stop"); +// const samplesInput = screen.getByTestId("input-linspace-samples"); +// userEvent.type(startInput, "{backspace}3"); +// userEvent.type(stopInput, "{backspace}4"); +// userEvent.type(samplesInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("linspace"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.start).toBe(3); +// expect(contents?.[title]?.stop).toBe(4); +// expect(contents?.[title]?.samples).toBe(5); +// }); + +// it("should create new experiment (type: arange)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Arange sweeping"); +// userEvent.click(arangeElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Arange Sweeping +// const startInput = screen.getByTestId("input-arange-start"); +// const stopInput = screen.getByTestId("input-arange-stop"); +// const incrementInput = screen.getByTestId("input-arange-increment"); +// userEvent.type(startInput, "{backspace}3"); +// userEvent.type(stopInput, "{backspace}4"); +// userEvent.type(incrementInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("arange"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.start).toBe(3); +// expect(contents?.[title]?.stop).toBe(4); +// expect(contents?.[title]?.increment).toBe(5); +// }); + +// it("should create new experiment (type: MonteCarlo, distribution: normal)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Monte Carlo sweeping"); +// userEvent.click(arangeElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// const samplesInput = screen.getByTestId("input-montecarlo-samples"); +// const stdInput = screen.getByTestId("input-montecarlo-normal-std"); +// const meanInput = screen.getByTestId("input-montecarlo-normal-mean"); +// userEvent.type(samplesInput, "{backspace}3"); +// userEvent.type(stdInput, "{backspace}4"); +// userEvent.type(meanInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("monte-carlo"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.samples).toBe(3); +// expect(contents?.[title]?.distribution).toBe("normal"); +// expect(contents?.[title]?.std).toBe(4); +// expect(contents?.[title]?.mean).toBe(5); +// }); + +// it("should create new experiment (type: MonteCarlo, distribution: log-normal)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Monte Carlo sweeping"); +// userEvent.click(arangeElement); +// // distribution selection +// const distributionDropdown = screen.getByText("normal").nextSibling; +// userEvent.click(distributionDropdown as TargetElement); +// const logNormalElement = screen.getByText("log-normal"); +// userEvent.click(logNormalElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Arange Sweeping +// const samplesInput = screen.getByTestId("input-montecarlo-samples"); +// const muInput = screen.getByTestId("input-montecarlo-lognormal-mu"); +// const sigmaInput = screen.getByTestId("input-montecarlo-lognormal-sigma"); +// userEvent.type(samplesInput, "{backspace}3"); +// userEvent.type(muInput, "{backspace}4"); +// userEvent.type(sigmaInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("monte-carlo"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.samples).toBe(3); +// expect(contents?.[title]?.distribution).toBe("log-normal"); +// expect(contents?.[title]?.mu).toBe(4); +// expect(contents?.[title]?.sigma).toBe(5); +// }); + +// it("should create new experiment (type: MonteCarlo, distribution: poisson)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Monte Carlo sweeping"); +// userEvent.click(arangeElement); +// // distribution selection +// const distributionDropdown = screen.getByText("normal").nextSibling; +// userEvent.click(distributionDropdown as TargetElement); +// const logNormalElement = screen.getByText("poisson"); +// userEvent.click(logNormalElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Arange Sweeping +// const samplesInput = screen.getByTestId("input-montecarlo-samples"); +// const rateInput = screen.getByTestId("input-montecarlo-poisson-rate"); +// userEvent.type(samplesInput, "{backspace}3"); +// userEvent.type(rateInput, "{backspace}4"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("monte-carlo"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.samples).toBe(3); +// expect(contents?.[title]?.distribution).toBe("poisson"); +// expect(contents?.[title]?.rate).toBe(4); +// }); + +// it("should create new experiment (type: MonteCarlo, distribution: beta)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Monte Carlo sweeping"); +// userEvent.click(arangeElement); +// // distribution selection +// const distributionDropdown = screen.getByText("normal").nextSibling; +// userEvent.click(distributionDropdown as TargetElement); +// const betaElement = screen.getByText("beta"); +// userEvent.click(betaElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Arange Sweeping +// const samplesInput = screen.getByTestId("input-montecarlo-samples"); +// const alphaInput = screen.getByTestId("input-montecarlo-beta-alpha"); +// const betaInput = screen.getByTestId("input-montecarlo-beta-beta"); +// userEvent.type(samplesInput, "{backspace}3"); +// userEvent.type(alphaInput, "{backspace}4"); +// userEvent.type(betaInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("monte-carlo"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.samples).toBe(3); +// expect(contents?.[title]?.distribution).toBe("beta"); +// expect(contents?.[title]?.alpha).toBe(4); +// expect(contents?.[title]?.beta).toBe(5); +// }); + +// it("should create new experiment (type: MonteCarlo, distribution: gamma)", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Monte Carlo sweeping"); +// userEvent.click(arangeElement); +// // distribution selection +// const distributionDropdown = screen.getByText("normal").nextSibling; +// userEvent.click(distributionDropdown as TargetElement); +// const betaElement = screen.getByText("gamma"); +// userEvent.click(betaElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Arange Sweeping +// const samplesInput = screen.getByTestId("input-montecarlo-samples"); +// const alphaInput = screen.getByTestId("input-montecarlo-gamma-shape"); +// const betaInput = screen.getByTestId("input-montecarlo-gamma-scale"); +// userEvent.type(samplesInput, "{backspace}3"); +// userEvent.type(alphaInput, "{backspace}4"); +// userEvent.type(betaInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("monte-carlo"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.samples).toBe(3); +// expect(contents?.[title]?.distribution).toBe("gamma"); +// expect(contents?.[title]?.shape).toBe(4); +// expect(contents?.[title]?.scale).toBe(5); +// }); + +// it("should discard and leave experiments.json intact", () => { +// store.dispatch(setProjectWithMeta(mockProject)); +// const mockFn = jest.fn(); +// render( +// +// +// +// +// +// ); +// const typeDropdown = screen.getByText("Value sweeping").nextSibling; +// const titleInput = screen.getByPlaceholderText("Experiment title"); +// const submitButton = screen.getByText("Create experiment"); +// const title = "Wonderful title"; +// userEvent.click(typeDropdown as TargetElement); +// const arangeElement = screen.getByText("Monte Carlo sweeping"); +// userEvent.click(arangeElement); +// // distribution selection +// const distributionDropdown = screen.getByText("normal").nextSibling; +// userEvent.click(distributionDropdown as TargetElement); +// const betaElement = screen.getByText("gamma"); +// userEvent.click(betaElement); +// userEvent.type(titleInput, title); +// const fieldDropdown = screen.getByText("FIELD").parentElement?.nextSibling; +// userEvent.click(fieldDropdown as TargetElement); +// userEvent.type(fieldDropdown as TargetElement, "{arrowdown}"); +// const firstFieldDropdown = screen.getByText("onion"); +// userEvent.click(firstFieldDropdown); +// // // these 3 inputs are only visible _after_ we select Arange Sweeping +// const samplesInput = screen.getByTestId("input-montecarlo-samples"); +// const alphaInput = screen.getByTestId("input-montecarlo-gamma-shape"); +// const betaInput = screen.getByTestId("input-montecarlo-gamma-scale"); +// userEvent.type(samplesInput, "{backspace}3"); +// userEvent.type(alphaInput, "{backspace}4"); +// userEvent.type(betaInput, "{backspace}5"); +// userEvent.click(submitButton); +// expect(mockFn).toHaveBeenCalled(); +// const state = store.getState(); +// const contents: any = JSON.parse( +// state?.files?.entities?.experiments?.contents || "" +// ); +// expect(contents?.[title]?.type).toBe("monte-carlo"); +// expect(contents?.[title]?.steps).toBe(100); +// expect(contents?.[title]?.field).toBe("onion"); +// expect(contents?.[title]?.samples).toBe(3); +// expect(contents?.[title]?.distribution).toBe("gamma"); +// expect(contents?.[title]?.shape).toBe(4); +// expect(contents?.[title]?.scale).toBe(5); +// }); + +// it.todo("Edit: should not delete everything after saving"); +// }); diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.tsx b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.tsx new file mode 100644 index 0000000..94d1d3d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentModal.tsx @@ -0,0 +1,1494 @@ +import React, { + ChangeEvent, + FC, + FormEvent, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { ProviderTargetEnv } from "@hashintel/engine-web"; +import { Result, combine, ok } from "neverthrow"; +import { omit, pick } from "lodash"; +import { v4 as uuid } from "uuid"; + +import { + AllFormDataTypeDynamicFieldsType, + DistributionTypes, + ExperimentTypes, + FormDataDynamicFieldOptimizationErrorsType, + FormDataDynamicFieldOptimizationFieldType, + FormDataDynamicFieldOptimizationMetricObjective, + FormDataDynamicFieldOptimizationType, + FormDataType, + FormErrorsType, + ParseError, + ParseResult, + RawExperimentOptimizationField, + RawExperimentOptimizationType, + RawExperimentType, +} from "./types"; +import { ExperimentTypeTooltip } from "./ExperimentTypeTooltip"; +import { FancyButton } from "../../Fancy"; +import { FancyButtonWithDropdown } from "../../Fancy/Button/FancyButtonWithDropdown"; +import { IconHelpCircleOutline } from "../../Icon/HelpCircleOutline"; +import { IconTrash } from "../../Icon"; +import { Modal, ModalFormEntryDropdown, ModalFormEntryRequiredText } from ".."; +import { ModalExit } from "../ModalExit"; +import { ModalFormEntryLabel } from "../FormEntry/ModalFormEntryLabel"; +import { ReactSelectOption } from "../../Dropdown/types"; +import { RoundedSelect } from "../../Inputs/Select/RoundedSelect"; +import { addUserAlert } from "../../../features/viewer/slice"; +import { + convertToReactSelectOption, + convertToReactSelectOptions, + errorsTypeHasError, + flattenObjectKeysIntoString, + formErrorsTypeFromDataType, + getErrorClassname, +} from "./utils"; +import { + experimentsFileId, + stringifyExperiments, +} from "../../../features/files/utils"; +import { + isRange, + parseValuesFromInput, + serializeParsedValues, +} from "./valuesParser"; +import { parseGlobals } from "../../GlobalsEditor/utils"; +import { queueExperiment } from "../../../features/simulator/simulate/queueExperiment"; +import { selectExperiments } from "../../SimulationRunner/Controls/Experiments/selectors"; +import { + selectGlobals, + selectParsedAnalysisMetricNames, +} from "../../../features/files/selectors"; +import { selectProviderTarget } from "../../../features/simulator/simulate/selectors"; +import { toggleProviderTarget } from "../../../features/simulator/simulate/thunks"; +import { updateFile } from "../../../features/files/slice"; +import { useRefState } from "../../../hooks/useRefState"; +import { + useSimulatorDispatch, + useSimulatorSelector, +} from "../../../features/simulator/context"; + +import "./ExperimentModal.scss"; + +const typeOptions: ReactSelectOption[] = [ + { + value: ExperimentTypes.values, + label: "Value sweeping", + }, + { + value: ExperimentTypes.linspace, + label: "Linspace sweeping", + }, + { + value: ExperimentTypes.arange, + label: "Arange sweeping", + }, + { + value: ExperimentTypes.monteCarlo, + label: "Monte Carlo sweeping", + }, + { + value: ExperimentTypes.group, + label: "Group", + }, + { + value: ExperimentTypes.multiparameter, + label: "Multiparameter", + }, + { + value: ExperimentTypes.optimization, + label: "Optimization", + }, +]; + +const monteCarloDistributionOptions: ReactSelectOption[] = [ + DistributionTypes.normal, + DistributionTypes.logNormal, + DistributionTypes.poisson, + DistributionTypes.beta, + DistributionTypes.gamma, +].map((distribution) => ({ value: distribution, label: distribution })); + +const optimizationMetricObjectiveOptions: ReactSelectOption[] = [ + FormDataDynamicFieldOptimizationMetricObjective.max, + FormDataDynamicFieldOptimizationMetricObjective.min, +].map((value) => ({ value, label: value })); + +const stepsInputProps = { + type: "number", + min: 1, + step: 1, + label: "STEPS", + errorMessage: "", +}; + +const fieldInputProps = { + label: "FIELD", + placeholder: "Global parameter", + errorMessage: "", +}; + +const defaultFieldOption = { + value: "", + label: "", +} as ReactSelectOption; + +const initialFormDynamicFieldsData: Required = { + values: { + steps: 100, + field: defaultFieldOption, + values: "", + }, + linspace: { + steps: 100, + field: defaultFieldOption, + start: 0, + stop: 0, + samples: 0, + }, + arange: { + steps: 100, + field: defaultFieldOption, + start: 0, + stop: 0, + increment: 0, + }, + "monte-carlo": { + steps: 100, + samples: 1, + field: defaultFieldOption, + distribution: monteCarloDistributionOptions[0], + mean: 0, + std: 0, + mu: 0, + sigma: 0, + rate: 0, + alpha: 0, + beta: 0, + shape: 0, + scale: 0, + }, + group: { + steps: 100, + runs: [], + }, + multiparameter: { + steps: 100, + runs: [], + }, + optimization: { + maxRuns: 10, + minSteps: 10, + maxSteps: 100, + metricName: { + value: "", + label: "", + }, + metricObjective: { + value: FormDataDynamicFieldOptimizationMetricObjective.max, + label: FormDataDynamicFieldOptimizationMetricObjective.max, + }, + fields: [], + }, +}; + +export const initialFormData = { + experimentTitle: "", + experimentType: typeOptions[0], + dynamicFields: initialFormDynamicFieldsData, +}; + +const getFieldsForMonteCarloDistribution = ( + distribution: DistributionTypes +): string[] => { + switch (distribution) { + case DistributionTypes.logNormal: + return ["mu", "sigma"]; + + case DistributionTypes.poisson: + return ["rate"]; + + case DistributionTypes.beta: + return ["alpha", "beta"]; + + case DistributionTypes.gamma: + return ["shape", "scale"]; + + case DistributionTypes.normal: + default: + return ["mean", "std"]; + } +}; + +const optimizationFieldTemplate = () => ({ + name: "", + uuid: uuid(), + value: "", +}); + +const prepareExperimentForFormData = ( + experiment?: RawExperimentType +): FormDataType | undefined => { + if (!experiment) { + return; + } + /** + * This isn't right – this is still the raw type at this point. + * + * @todo fix this + */ + const clone: FormDataType = JSON.parse(JSON.stringify(experiment)); + const experimentType = experiment.experimentType as ExperimentTypes; + clone.experimentType = convertToReactSelectOption(experimentType); + if (experimentType === ExperimentTypes.values) { + if (clone.dynamicFields[ExperimentTypes.values]) { + const originalValues = experiment.dynamicFields[ExperimentTypes.values]! + .values; + clone.dynamicFields[ + ExperimentTypes.values + ]!.values = serializeParsedValues(originalValues); + } + } + if (experimentType === ExperimentTypes.optimization) { + if (clone.dynamicFields[ExperimentTypes.optimization]) { + const originalFields = + experiment.dynamicFields[ExperimentTypes.optimization]; + const cloneFields = clone.dynamicFields[ExperimentTypes.optimization]; + + if (cloneFields) { + if (Array.isArray(cloneFields.fields) && cloneFields.fields.length) { + // @todo remove this casting + cloneFields.fields = ((cloneFields as any) as RawExperimentOptimizationType).fields.map( + ( + field: RawExperimentOptimizationField + ): FormDataDynamicFieldOptimizationFieldType => { + const newField = { + name: field.name, + uuid: uuid(), + }; + + if ("range" in field) { + return { + ...newField, + value: + typeof (field.range as unknown) === "string" + ? field.range + : "", + }; + } else if (Array.isArray(field.values)) { + return { + ...newField, + value: serializeParsedValues(field.values), + }; + } else { + return { ...newField, value: "" }; + } + } + ); + } else { + cloneFields.fields = [optimizationFieldTemplate()]; + } + + if (originalFields?.metricName) { + cloneFields!.metricName = { + value: originalFields.metricName, + label: originalFields.metricName, + }; + } + if ( + originalFields?.metricObjective === + FormDataDynamicFieldOptimizationMetricObjective.min || + originalFields?.metricObjective === + FormDataDynamicFieldOptimizationMetricObjective.max + ) { + cloneFields!.metricObjective = { + value: originalFields?.metricObjective, + label: originalFields?.metricObjective, + }; + } else { + cloneFields!.metricObjective = + initialFormDynamicFieldsData[ + ExperimentTypes.optimization + ].metricObjective; + } + } + } + + return clone; + } + if ( + experimentType === ExperimentTypes.group || + experimentType === ExperimentTypes.multiparameter + ) { + // these experiment types do not use a `field` property but a 'runs' + let value = clone.dynamicFields?.[experimentType]?.runs; + if (Array.isArray(value) && value.length > 0) { + value = convertToReactSelectOptions(value.join(",")); + } else { + value = null; + } + clone.dynamicFields![experimentType]!.runs = value; + return clone; + } + const fieldValue = experiment.dynamicFields?.[experimentType]?.field; + const distributionValue = + experiment.dynamicFields?.["monte-carlo"]?.distribution; + const hasFieldTypeString = typeof fieldValue === "string"; + const hasDistributionTypeString = typeof distributionValue === "string"; // monte-carlo case + if (hasFieldTypeString) { + clone.dynamicFields![experimentType]!.field = convertToReactSelectOption( + fieldValue + ); + } + if (hasDistributionTypeString) { + clone.dynamicFields![ + "monte-carlo" + ]!.distribution = convertToReactSelectOption(distributionValue); + } + return clone; +}; + +const onSubmitSpecificExperimentHandler = ( + experimentType: ExperimentTypes, + formData: FormDataType, + fields: AllFormDataTypeDynamicFieldsType +): Result => { + let clone = JSON.parse(JSON.stringify(fields)); + switch (experimentType) { + case ExperimentTypes.monteCarlo: + // we need only the fields for the specific distribution + clone.field = clone.field.value; + clone.distribution = clone.distribution.value; + clone = pick(clone, [ + "steps", + "samples", + "field", + "distribution", + ...getFieldsForMonteCarloDistribution(clone.distribution), + ]); + return ok(clone); + + case ExperimentTypes.values: + if (typeof clone.values === "string") { + const res = parseValuesFromInput(clone.values).mapErr( + (error: ParseError) => { + const formErrors = formErrorsTypeFromDataType(formData); + formErrors.dynamicFields![experimentType]!.values = + error.msg ?? "Error parsing values"; + return formErrors; + } + ); + if (res.isOk()) { + clone.values = res.unwrapOr([]); + } + if (res.isErr()) { + return res; + } + } + clone.field = clone.field.value; + return ok(clone); + + case ExperimentTypes.group: + case ExperimentTypes.multiparameter: + clone.runs = clone.runs?.map((run: ReactSelectOption) => run.value) ?? []; + return ok(clone); + + case ExperimentTypes.optimization: + clone.metricName = clone.metricName.value; + clone.metricObjective = clone.metricObjective.value; + let formErrors: FormErrorsType | null = null; + const results = clone.fields.map( + ( + field: FormDataDynamicFieldOptimizationFieldType, + idx: number + ): Result => { + const parsedValue: ParseResult = parseValuesFromInput( + field.value + ); + + return parsedValue + .map((values) => ({ + name: field.name, + ...(values.length && isRange(values[0]) + ? { range: field.value } + : { values: values }), + })) + .mapErr((error) => { + if (formErrors === null) { + // Prepare `formErrors` + formErrors = formErrorsTypeFromDataType(formData); + } + formErrors.dynamicFields![experimentType]!.fields![idx].value = + error.msg ?? "Error parsing values"; + }); + } + ); + + const res = combine(results) + .map((fields) => { + clone.fields = fields; + }) + .mapErr(() => formErrors!); + if (res.isErr()) { + return res; + } + return ok(clone); + + default: + if (clone.field && clone.field.value) { + clone.field = clone.field.value; + } + return ok(clone); + } +}; + +export const ExperimentModal: FC<{ + onClose: () => void; + experiment?: RawExperimentType; +}> = ({ onClose, experiment }) => { + // EFFECTS + const [formErrors, setFormErrors] = useState({}); + const [formData, setFormData] = useState( + () => prepareExperimentForFormData(experiment) ?? initialFormData + ); + const shouldRunExperimentAfterSaving = useRef(false); + const dispatch = useDispatch(); + const simulationTarget = useSimulatorSelector(selectProviderTarget); + const [ + newSimulationTarget, + setNewSimulationTarget, + newSimulationTargetRef, + ] = useRefState(simulationTarget); + const simulatorDispatch = useSimulatorDispatch(); + const experiments: [string, RawExperimentType][] | null = useSelector( + selectExperiments + ); + const globals = parseGlobals(useSelector(selectGlobals)); + const fieldOptions = + (globals?.globals + ? flattenObjectKeysIntoString(globals.globals).map((global: string) => + convertToReactSelectOption(global) + ) + : null) ?? []; + const metricOptions = useSelector(selectParsedAnalysisMetricNames).map( + (name): ReactSelectOption => ({ value: name, label: name }) + ); + const canUseCloud = false; + + // FUNCTIONS + const validate = () => { + setFormErrors({}); + if (formData.experimentTitle.trim() === "") { + setFormErrors({ experimentTitle: "This field is required" }); + return false; + } + if (experimentTitles.includes(formData.experimentTitle) && !experiment) { + setFormErrors({ + experimentTitle: "An experiment with that title already exists", + }); + return false; + } + const experimentType = formData.experimentType.value as ExperimentTypes; + const fields = + formData.dynamicFields[experimentType] ?? + // this condition is hit when you switch the Experiment Type + you are editing an Experiment + initialFormData.dynamicFields[experimentType]; + + const formErrors = formErrorsTypeFromDataType(formData); + const errors = formErrors.dynamicFields![experimentType]!; + if (experimentType === ExperimentTypes.optimization) { + const optimizationFields = fields as FormDataDynamicFieldOptimizationType; + if (optimizationFields.minSteps > optimizationFields.maxSteps) { + (errors as FormDataDynamicFieldOptimizationErrorsType).minSteps = + "minSteps cannot be larger than maxSteps"; + } + } + + setFormErrors(formErrors); + return !errorsTypeHasError(formErrors); + }; + + const onSubmit = (evt: FormEvent): void => { + evt.preventDefault(); + if (!validate()) { + return; + } + const experimentType: ExperimentTypes | string = + formData.experimentType.value; + let fields = JSON.parse( + //@ts-ignore + JSON.stringify(formData.dynamicFields[experimentType]) + ); + + const res = onSubmitSpecificExperimentHandler( + experimentType as ExperimentTypes, + formData, + fields + ).mapErr((err) => { + setFormErrors(err); + }); + if (res.isOk()) { + fields = res.unwrapOr(undefined)!; + } + + if (res.isErr()) { + return; + } + + for (const fieldName in fields) { + const val = fields[fieldName]; + if ( + fieldName !== "field" && + typeof val !== "number" && + !isNaN(val) && + !isNaN(parseFloat(val)) + ) { + fields[fieldName] = Array.isArray(val) + ? val.map((num) => parseFloat(num)) + : parseFloat(val); + } + } + + const result: any = { + title: formData.experimentTitle, + type: experimentType, + ...fields, + }; + + // in case the JSON is malformed + const newExperiments: any = {}; + newExperiments[result.title] = omit(result, "title"); + if (experiments && experiments.length > 0) { + experiments.forEach((exp: any) => { + if (exp[0] !== result.title) { + newExperiments[exp[0]] = exp[1]; + } + }); + } + // if we are editing and title changed, do not add the old entry into the new results + if (experiment && formData.experimentTitle !== experiment.experimentTitle) { + delete newExperiments[experiment.experimentTitle]; + } + const contents = stringifyExperiments(newExperiments); + dispatch(updateFile({ id: experimentsFileId, contents })); + if (shouldRunExperimentAfterSaving.current) { + // switch the simulation environment target if the user changed it + if (newSimulationTargetRef.current !== simulationTarget) { + simulatorDispatch(toggleProviderTarget(newSimulationTargetRef.current)); + } + simulatorDispatch(queueExperiment(result.title)); + } + onClose(); + }; + + /** + * @todo should just use lodash set notation for fieldName parsing + */ + const onChange = ( + fieldName: string, + value: number | string | ReactSelectOption | ChangeEvent + ): void => { + const clone: any = JSON.parse(JSON.stringify(formData)); + const splitted = fieldName.split("."); + if (splitted.length === 1) { + clone[fieldName] = value; + // @ts-ignore + if (fieldName === "experimentType" && value.value !== clone[fieldName]) { + // we changed the experiment type, thus we have to rebuild the dynamicFields + const clonedDynamicFields: typeof initialFormData.dynamicFields = JSON.parse( + JSON.stringify(initialFormData.dynamicFields) + ); + + clonedDynamicFields.optimization.fields = [optimizationFieldTemplate()]; + clonedDynamicFields.optimization.metricName = metricOptions[0]; + clone.dynamicFields = clonedDynamicFields; + } + setFormData(clone); + if (value !== "") { + setFormErrors({ ...omit(formErrors, fieldName) }); + } + return; + } + const [experimentType, field, ...pieces] = splitted; + if (pieces.length === 0) { + clone.dynamicFields[experimentType][field] = value; + } else { + let target = clone.dynamicFields[experimentType][field]; + while (pieces.length > 1) { + target = target[pieces.shift()!]; + } + target[pieces[pieces.length - 1]] = value; + } + setFormData(clone); + if (value !== "") { + // clear errors for this field + const newErrors: any = JSON.parse(JSON.stringify(formErrors)); + if (newErrors.dynamicFields && newErrors.dynamicFields[experimentType]) { + delete newErrors?.dynamicFields[experimentType][field]; + } + setFormErrors(newErrors); + } + }; + + const shouldShowType = (experimentType: ExperimentTypes) => + formData.experimentType.value === experimentType; + + const shouldShowMonteCarlo = (distribution: DistributionTypes) => { + const defaultOpt = { + label: DistributionTypes.normal, + value: DistributionTypes.normal, + }; + const currentDistribution: ReactSelectOption = + formData.dynamicFields?.["monte-carlo"]?.distribution ?? defaultOpt; + return currentDistribution.value === String(distribution); + }; + + useLayoutEffect(() => { + if (experiments) { + return; + } + console.error("experiments.json is malformed, closing modal"); + dispatch( + addUserAlert({ + type: "error", + message: `You can't use the Experiments visual editor because your experiments file has a typo.`, + context: "experiments.json", + timestamp: Date.now(), + simulationId: null, + }) + ); + onClose(); + }, [experiments, onClose, dispatch]); + + const hasExperiments = experiments && experiments.length > 0; + const experimentTitles = !hasExperiments + ? [] + : experiments?.map((item: any) => item[0]) ?? []; + const experimentTitlesMinusGroupsAndMultiparameterAsOptions = !hasExperiments + ? [] + : experiments + ?.filter( + (item: any) => + item[1].type !== "multiparameter" && item[1].type !== "group" + ) + .map((item: any) => convertToReactSelectOption(item[0])) ?? []; + + const showValues = shouldShowType(ExperimentTypes.values); + const showLinspace = shouldShowType(ExperimentTypes.linspace); + const showArange = shouldShowType(ExperimentTypes.arange); + const showMonteCarlo = shouldShowType(ExperimentTypes.monteCarlo); + const showGroup = shouldShowType(ExperimentTypes.group); + const showMultiparameter = shouldShowType(ExperimentTypes.multiparameter); + + const showMonteCarloNormal = shouldShowMonteCarlo(DistributionTypes.normal); + const showMonteCarloLogNormal = shouldShowMonteCarlo( + DistributionTypes.logNormal + ); + const showMonteCarloPoisson = shouldShowMonteCarlo(DistributionTypes.poisson); + const showMonteCarloBeta = shouldShowMonteCarlo(DistributionTypes.beta); + const showMonteCarloGamma = shouldShowMonteCarlo(DistributionTypes.gamma); + + return ( + +
    + + GET HELP{" "} + + + + + {onClose && } +
    + +
    +
    +

    {!experiment ? "Create a new experiment" : "Edit experiment"}

    + {!experiment && ( +

    + Creating a new experiment will add an experiment to your + experiments.json file which can be run at any time. +

    + )} + +
    + + onChange("experimentType", selectedOption) + } + data-testid="dropdown-type" + /> + +
    + +
    + onChange("experimentTitle", evt.target.value)} + /> +
    + +
    + {showValues && ( + <> + onChange("values.steps", evt.target.value)} + /> + + onChange("values.field", selectedOption) + } + data-testid="dropdown-values-field" + /> + + + onChange("values.values", evt.target.value) + } + /> + + )} + + {showLinspace && ( + <> + + onChange("linspace.steps", evt.target.value) + } + /> + + onChange("linspace.field", selectedOption) + } + data-testid="dropdown-linspace-field" + /> + + onChange("linspace.start", evt.target.value) + } + data-testid="input-linspace-start" + /> + + onChange("linspace.stop", evt.target.value) + } + data-testid="input-linspace-stop" + /> + + onChange("linspace.samples", evt.target.value) + } + data-testid="input-linspace-samples" + /> + + )} + + {showArange && ( + <> + onChange("arange.steps", evt.target.value)} + /> + + onChange("arange.field", selectedOption) + } + data-testid="dropdown-arange-field" + /> + onChange("arange.start", evt.target.value)} + data-testid="input-arange-start" + /> + onChange("arange.stop", evt.target.value)} + data-testid="input-arange-stop" + /> + + onChange("arange.increment", evt.target.value) + } + data-testid="input-arange-increment" + /> + + )} + + {showMonteCarlo && ( + <> + + onChange("monte-carlo.steps", evt.target.value) + } + /> + + onChange("monte-carlo.field", selectedOption) + } + data-testid="dropdown-monte-carlo-field" + className={getErrorClassname( + !!formErrors.dynamicFields?.["monte-carlo"]?.field + )} + /> + + onChange("monte-carlo.samples", evt.target.value) + } + data-testid="input-montecarlo-samples" + /> + + onChange("monte-carlo.distribution", selectedOption) + } + data-testid="dropdown-monte-carlo-distribution" + /> + {showMonteCarloNormal && ( + <> + + onChange("monte-carlo.std", evt.target.value) + } + data-testid="input-montecarlo-normal-std" + /> + + onChange("monte-carlo.mean", evt.target.value) + } + data-testid="input-montecarlo-normal-mean" + /> + + )} + {showMonteCarloLogNormal && ( + <> + + onChange("monte-carlo.mu", evt.target.value) + } + data-testid="input-montecarlo-lognormal-mu" + /> + + onChange("monte-carlo.sigma", evt.target.value) + } + data-testid="input-montecarlo-lognormal-sigma" + /> + + )} + {showMonteCarloPoisson && ( + + onChange("monte-carlo.rate", evt.target.value) + } + data-testid="input-montecarlo-poisson-rate" + /> + )} + {showMonteCarloBeta && ( + <> + + onChange("monte-carlo.alpha", evt.target.value) + } + data-testid="input-montecarlo-beta-alpha" + /> + + onChange("monte-carlo.beta", evt.target.value) + } + data-testid="input-montecarlo-beta-beta" + /> + + )} + {showMonteCarloGamma && ( + <> + + onChange("monte-carlo.shape", evt.target.value) + } + data-testid="input-montecarlo-gamma-shape" + /> + + onChange("monte-carlo.scale", evt.target.value) + } + data-testid="input-montecarlo-gamma-scale" + /> + + )} + + )} + + {showGroup && ( + <> + onChange("group.steps", evt.target.value)} + /> + { + onChange( + "group.runs", + selectedOptions === null ? [] : selectedOptions + ); + }} + data-testid="dropdown-group-runs" + /> + + )} + + {showMultiparameter && ( + <> + + onChange("multiparameter.steps", evt.target.value) + } + /> + { + onChange( + "multiparameter.runs", + selectedOptions === null ? [] : selectedOptions + ); + }} + data-testid="dropdown-multiparameter-runs" + /> + + )} + {shouldShowType(ExperimentTypes.optimization) ? ( + metricOptions.length === 0 ? ( +

    + You must define some analysis metrics in order to use this + experiment type. +

    + ) : ( + <> + + onChange( + "optimization.maxRuns", + parseInt(evt.target.value, 10) + ) + } + // @todo this casting shouldn't be necessary + errorMessage={ + formErrors.dynamicFields?.optimization?.maxRuns! + } + /> + + onChange( + "optimization.minSteps", + parseInt(evt.target.value, 10) + ) + } + // @todo this casting shouldn't be necessary + errorMessage={ + formErrors.dynamicFields?.optimization?.minSteps! + } + /> + + onChange( + "optimization.maxSteps", + parseInt(evt.target.value, 10) + ) + } + /> + { + onChange("optimization.metricName", selectedOption); + }} + /> + + onChange("optimization.metricObjective", selectedOption) + } + /> + + ) + ) : null} +
    + {shouldShowType(ExperimentTypes.optimization) ? ( + <> +
    +
    +
    +
    + Field +
    +
    + + Value{" "} + + (Comma, separated, list, or range delimited by "-") + + +
    +
    + {( + formData.dynamicFields[ExperimentTypes.optimization] + ?.fields ?? [] + ).map((field, idx, rows) => ( +
    +
    + + onChange( + `optimization.fields.${idx}.name`, + evt.target.value + ) + } + required + /> +
    +
    + { + onChange( + `optimization.fields.${idx}.value`, + evt.target.value + ); + }} + placeholder="Value" + errorMessage={ + formErrors.dynamicFields?.optimization?.fields?.[ + idx + ]?.value + } + /> +
    + {rows.length > 1 && ( +
    + { + evt.preventDefault(); + const clone: typeof formData = JSON.parse( + JSON.stringify(formData) + ); + clone.dynamicFields[ + ExperimentTypes.optimization + ]?.fields.splice(idx, 1); + setFormData(clone); + }} + theme="transparent" + > + + +
    + )} +
    + ))} +
    +
    + { + evt.preventDefault(); + const clone: typeof formData = JSON.parse( + JSON.stringify(formData) + ); + const fields = + clone.dynamicFields[ExperimentTypes.optimization]?.fields ?? + []; + + fields.push(optimizationFieldTemplate()); + setFormData(clone); + setImmediate(() => { + document + .querySelector( + `[name="fields.${fields.length - 1}.name"]` + ) + ?.focus(); + }); + }} + > + Add Field + + + ) : null} +
    +
    + + Discard + + { + shouldRunExperimentAfterSaving.current = false; + }} + theme="black" + type="submit" + > + Save without running + + + {shouldShowType(ExperimentTypes.optimization) ? ( + canUseCloud ? ( + { + shouldRunExperimentAfterSaving.current = true; + setNewSimulationTarget("cloud"); + }} + theme="blue" + type="submit" + > + Save and run in hCloud + + ) : null + ) : canUseCloud ? ( + { + shouldRunExperimentAfterSaving.current = true; + }} + type="submit" + theme="blue" + dropdownOptions={ + simulationTarget === "cloud" + ? [ + { label: "cloud", value: "cloud" }, + { label: "local", value: "local" }, + ] + : [ + { label: "local", value: "local" }, + { label: "cloud", value: "cloud" }, + ] + } + onOptionSelect={(option: ReactSelectOption) => { + const value = option.value as ProviderTargetEnv; + setNewSimulationTarget(value); + }} + > + Save and run{" "} + {newSimulationTarget === "cloud" ? "in hCloud" : "locally"} + + ) : ( + { + shouldRunExperimentAfterSaving.current = true; + setNewSimulationTarget("web"); + }} + theme="blue" + type="submit" + > + Save and run locally + + )} +
    +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentTypeTooltip.tsx b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentTypeTooltip.tsx new file mode 100644 index 0000000..7c5f714 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/ExperimentTypeTooltip.tsx @@ -0,0 +1,74 @@ +import React, { FC } from "react"; + +import { ExperimentTypes } from "./types"; +import { IconInformationOutline } from "../../Icon"; +import { SimpleTooltip } from "../../SimpleTooltip"; + +const BASE_DOCS_URL = + "https://docs.hash.ai/core/creating-simulations/experiments/"; + +const BASE_REGULAR_EXP_URL = `${BASE_DOCS_URL}experiment-types`; + +interface ExperimentTypeHints + extends Record {} + +const EXPERIMENT_TYPE_HINTS: ExperimentTypeHints = { + values: { + description: + "Value sweeping runs a simulation for each of the specified values.", + docsUrl: `${BASE_REGULAR_EXP_URL}#value-sweep`, + }, + linspace: { + description: + "Fixed sample sweeping or 'linspace' is one of the most common types of parameter sweeps. Define start, stop, and number of samples to generate an even sampling between two values with a set number of data points.", + docsUrl: `${BASE_REGULAR_EXP_URL}#fixed-sample-sweep-linspace`, + }, + arange: { + description: + "Instead of using a set number of samples like linspace, arange samples every 'increment' between the specified start and stop fields.", + docsUrl: `${BASE_REGULAR_EXP_URL}#fixed-step-sweep`, + }, + "monte-carlo": { + description: + "Monte Carlo sweeping allows random sampling from a custom distribution. Each supported distribution can be customized through the associated parameters.", + docsUrl: `${BASE_REGULAR_EXP_URL}#monte-carlo-sweep`, + }, + group: { + description: "You can run groups of experiments together at the same time.", + docsUrl: `${BASE_REGULAR_EXP_URL}#group-sweep`, + }, + multiparameter: { + description: + "In order to discover interaction effects in your model, you'll have to perform sweeps over multiple parameters. The multiparameter experiment generates a full factorial design with all of the specified experiments.", + docsUrl: `${BASE_REGULAR_EXP_URL}#multiparameter-sweep`, + }, + optimization: { + description: + "With HASH's optimization engine, you can automatically generate simulations and find the set of parameters that will maximize or minimize a metric.", + docsUrl: `${BASE_DOCS_URL}optimization-experiments`, + }, +}; + +export const ExperimentTypeTooltip: FC<{ type: ExperimentTypes }> = ({ + type, +}) => ( +
    + + + + + {EXPERIMENT_TYPE_HINTS[type].description} +
    +
    + + Read more. + +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/types.ts b/apps/sim-core/packages/core/src/components/Modal/Experiments/types.ts new file mode 100644 index 0000000..14d4a57 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/types.ts @@ -0,0 +1,234 @@ +import { Result } from "neverthrow"; + +import { ReactSelectOption } from "../../Dropdown/types"; + +export enum ExperimentTypes { + values = "values", + linspace = "linspace", + arange = "arange", + monteCarlo = "monte-carlo", + group = "group", + multiparameter = "multiparameter", + optimization = "optimization", +} + +export enum DistributionTypes { + normal = "normal", + logNormal = "log-normal", + poisson = "poisson", + beta = "beta", + gamma = "gamma", +} + +export type FormDataDynamicFieldValuesType = { + steps: number; + field: ReactSelectOption; + values: string; +}; +export type FormDataDynamicFieldValuesErrorsType = { + steps?: string; + field?: string; + values?: string; +}; + +export type FormDataDynamicFieldLinspaceType = { + steps: number; + field: ReactSelectOption; + start: number; + stop: number; + samples: number; +}; +export type FormDataDynamicFieldLinspaceErrorsType = { + steps?: string; + field?: string; + start?: string; + stop?: string; + samples?: string; +}; + +export type FormDataDynamicFieldArangeType = { + steps: number; + field: ReactSelectOption; + start: number; + stop: number; + increment: number; +}; +export type FormDataDynamicFieldArangeErrorsType = { + steps?: string; + field?: string; + start?: string; + stop?: string; + increment?: string; +}; + +export type FormDataDynamicFieldMonteCarloType = { + steps: number; + field: ReactSelectOption; + samples: number; + distribution: ReactSelectOption; + mean?: number; + std?: number; + mu?: number; + sigma?: number; + rate?: number; + alpha?: number; + beta?: number; + shape?: number; + scale?: number; +}; +export type FormDataDynamicFieldMonteCarloErrorsType = { + steps?: string; + field?: string; + samples?: string; + distribution?: string; + mean?: string; + std?: string; + mu?: string; + sigma?: string; + rate?: string; + alpha?: string; + beta?: string; + shape?: string; + scale?: string; +}; + +export type FormDataDynamicFieldGroupType = { + steps: number; + runs: ReactSelectOption[] | null; +}; +export type FormDataDynamicFieldGroupErrorsType = { + steps?: string; + runs?: string; +}; + +export type FormDataDynamicFieldMultiparameterType = { + steps: number; + runs: ReactSelectOption[] | null; // Used for react-select multi item +}; +export type FormDataDynamicFieldMultiparameterErrorsType = { + steps?: string; + runs?: string; +}; + +export enum FormDataDynamicFieldOptimizationMetricObjective { + min = "min", + max = "max", +} + +export type FormDataDynamicFieldOptimizationFieldType = { + name: string; + value: string; + uuid: string; +}; +export type FormDataDynamicFieldOptimizationFieldErrorsType = { + name?: string; + value?: string; + uuid?: string; +}; + +export type FormDataDynamicFieldOptimizationType = { + maxRuns: number; + minSteps: number; + maxSteps: number; + metricName: ReactSelectOption; + metricObjective: { + value: FormDataDynamicFieldOptimizationMetricObjective; + label: string; + }; + fields: FormDataDynamicFieldOptimizationFieldType[]; +}; +export type FormDataDynamicFieldOptimizationErrorsType = { + maxRuns?: string; + minSteps?: string; + maxSteps?: string; + metricName?: string; + metricObjective?: string; + fields?: FormDataDynamicFieldOptimizationFieldErrorsType[]; +}; + +export type FormDataType = { + experimentTitle: string; + experimentType: ReactSelectOption; + // ReactSelectOption | string => Used for react-select single item + dynamicFields: { + [ExperimentTypes.values]?: FormDataDynamicFieldValuesType; + [ExperimentTypes.linspace]?: FormDataDynamicFieldLinspaceType; + [ExperimentTypes.arange]?: FormDataDynamicFieldArangeType; + [ExperimentTypes.monteCarlo]?: FormDataDynamicFieldMonteCarloType; + [ExperimentTypes.group]?: FormDataDynamicFieldGroupType; + [ExperimentTypes.multiparameter]?: FormDataDynamicFieldMultiparameterType; + [ExperimentTypes.optimization]?: FormDataDynamicFieldOptimizationType; + }; +}; + +export type RawExperimentOptimizationFieldValue = + | { range: string } + | { values: (string | number)[] }; + +export type RawExperimentOptimizationField = { + name: string; +} & RawExperimentOptimizationFieldValue; + +export type RawExperimentOptimizationType = Omit< + FormDataDynamicFieldOptimizationType, + "metricName" | "metricObjective" | "fields" +> & { + metricName: string; + metricObjective: string; + fields: RawExperimentOptimizationField[]; +}; + +/** + * This is far too trusting of the incoming data – as its a JSON blob, the whole + * thing should essentially be of an "unknown" type + * + * @todo fix this + */ +export type RawExperimentType = { + experimentTitle: string; + experimentType: string; + dynamicFields: { + values?: Omit & { + field: string; + values: any[]; + }; + linspace?: Omit & { + field: string; + }; + arange?: Omit & { field: string }; + "monte-carlo"?: Omit< + FormDataDynamicFieldMonteCarloType, + "field" | "distribution" + > & { field: string; distribution: string }; + group?: Omit & { runs: string[] }; + multiparameter?: Omit & { + runs: string[]; + }; + optimization?: RawExperimentOptimizationType; + }; +}; + +export type DynamicFieldsErrorsType = { + [ExperimentTypes.values]?: FormDataDynamicFieldValuesErrorsType; + [ExperimentTypes.linspace]?: FormDataDynamicFieldLinspaceErrorsType; + [ExperimentTypes.arange]?: FormDataDynamicFieldArangeErrorsType; + [ExperimentTypes.monteCarlo]?: FormDataDynamicFieldMonteCarloErrorsType; + [ExperimentTypes.group]?: FormDataDynamicFieldGroupErrorsType; + [ExperimentTypes.multiparameter]?: FormDataDynamicFieldMultiparameterErrorsType; + [ExperimentTypes.optimization]?: FormDataDynamicFieldOptimizationErrorsType; +}; + +export type FormErrorsType = { + experimentTitle?: string; + dynamicFields?: DynamicFieldsErrorsType; +}; + +export type AllFormDataTypeDynamicFieldsType = Required< + FormDataType["dynamicFields"] +>[keyof FormDataType["dynamicFields"]]; + +export type ParseError = { + msg?: string; +}; + +export type ParseResult = Result; diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/utils.tsx b/apps/sim-core/packages/core/src/components/Modal/Experiments/utils.tsx new file mode 100644 index 0000000..3dc59da --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/utils.tsx @@ -0,0 +1,212 @@ +import { isPlainObject } from "lodash"; + +import { + DynamicFieldsErrorsType, + ExperimentTypes, + FormDataType, + FormErrorsType, +} from "./types"; +import { ReactSelectOption } from "../../Dropdown/types"; + +export const flattenObjectKeysIntoString = (obj: any): any => + Object.keys(obj).flatMap((key) => + isPlainObject(obj[key]) + ? [key].concat( + flattenObjectKeysIntoString(obj[key]).map( + (innerKey: string) => `${key}.${innerKey}` + ) + ) + : [key] + ); + +export const getErrorClassname = (field?: boolean): string => { + return field ? "ExperimentModal__ErroredField" : ""; +}; + +export const convertToReactSelectOption = ( + value?: string +): ReactSelectOption => ({ + label: value ?? "", + value: value ?? "", +}); + +export const convertToReactSelectOptions = (value?: string) => { + const splitted = value?.split(","); + if (!splitted || splitted.length === 1) { + return [convertToReactSelectOption(value)]; + } + return splitted.map((text) => convertToReactSelectOption(text)); +}; + +export const errorsTypeHasError = (errors: FormErrorsType): boolean => { + if (errors.experimentTitle) { + return true; + } + + const objectContainsString = (obj: Object | undefined): boolean => { + if (obj) { + for (const value of Object.values(obj)) { + if (typeof value === "object" && objectContainsString(value)) { + return true; + } + + if (typeof value === "string") { + return true; + } + } + } + return false; + }; + + return objectContainsString(errors.dynamicFields); +}; + +export const formErrorsTypeFromDataType = ( + formData: FormDataType +): FormErrorsType => { + let experimentTitle = undefined; + if (!formData.experimentTitle) { + experimentTitle = "Experiment title is required"; + } + const fields: DynamicFieldsErrorsType = {}; + if (ExperimentTypes.values === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.values]!; + fields[ExperimentTypes.values] = { + steps: + values.steps === undefined + ? "Number of steps must be chosen" + : undefined, + field: + values.field.value === undefined || values.field.value === "" + ? "Field must be chosen" + : undefined, + values: values.values === undefined ? "Values must be chosen" : undefined, + }; + } + + if (ExperimentTypes.linspace === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.linspace]!; + fields[ExperimentTypes.linspace] = { + steps: + values.steps === undefined + ? "Number of steps must be chosen" + : undefined, + field: + values.field.value === undefined || values.field.value === "" + ? "Field must be chosen" + : undefined, + start: + values.start === undefined ? "Start value must be chosen" : undefined, + stop: values.stop === undefined ? "Stop value must be chosen" : undefined, + samples: + values.samples === undefined + ? "Number samples of must be chosen" + : undefined, + }; + } + + if (ExperimentTypes.arange === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.arange]!; + fields[ExperimentTypes.arange] = { + steps: + values.steps === undefined + ? "Number of steps must be chosen" + : undefined, + field: + values.field.value === undefined || values.field.value === "" + ? "Field must be chosen" + : undefined, + start: + values.start === undefined ? "Start value must be chosen" : undefined, + stop: values.stop === undefined ? "Stop value must be chosen" : undefined, + increment: + values.increment === undefined + ? "Size of increment of must be chosen" + : undefined, + }; + } + + if (ExperimentTypes.monteCarlo === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.monteCarlo]!; + fields[ExperimentTypes.monteCarlo] = { + steps: + values.steps === undefined + ? "Number of steps must be chosen" + : undefined, + field: + values.field.value === undefined || values.field.value === "" + ? "Field must be chosen" + : undefined, + samples: + values.samples === undefined + ? "Number samples of must be chosen" + : undefined, + distribution: + values.distribution.value === undefined || + values.distribution.value === "" + ? "Distribution must be chosen" + : undefined, + }; + } + + if (ExperimentTypes.group === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.group]!; + fields[ExperimentTypes.group] = { + steps: + values.steps === undefined + ? "Number of steps must be chosen" + : undefined, + }; + } + + if (ExperimentTypes.multiparameter === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.multiparameter]!; + fields[ExperimentTypes.multiparameter] = { + steps: + values.steps === undefined + ? "Number of steps must be chosen" + : undefined, + }; + } + + if (ExperimentTypes.optimization === formData.experimentType.value) { + const values = formData.dynamicFields[ExperimentTypes.optimization]!; + fields[ExperimentTypes.optimization] = { + maxRuns: + values.maxRuns === undefined + ? "Maximum number of runs must be chosen" + : undefined, + minSteps: + values.minSteps === undefined + ? "Minimum number of steps must be chosen" + : undefined, + maxSteps: + values.maxSteps === undefined + ? "Maximum number of steps must be chosen" + : undefined, + metricName: + values.metricName.value === "" + ? "Metric name must be chosen" + : undefined, + metricObjective: + values.metricObjective === undefined + ? "Metric objective must be chosen" + : undefined, + fields: values.fields.map((optField) => ({ + name: + optField.name === undefined + ? "Optimization field name must be chosen" + : undefined, + value: + optField.value === undefined || optField.value === "" + ? "Optimization field value must be chosen" + : undefined, + })), + }; + } + + return { + experimentTitle, + dynamicFields: fields, + }; +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.spec.ts b/apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.spec.ts new file mode 100644 index 0000000..a7c9587 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.spec.ts @@ -0,0 +1,79 @@ +import { ok } from "neverthrow"; + +import { + isRange, + parseValuesFromInput, + serializeParsedValues, +} from "./valuesParser"; + +test("valuesParser numbers", () => + expect(parseValuesFromInput('234, "234"')).toEqual(ok([234, "234"]))); + +test("valuesParser strings", () => + expect(parseValuesFromInput(' abc , "abc"')).toEqual(ok(["abc", "abc"]))); + +describe("valuesParser JSON", () => { + test("list of strings", () => + expect(parseValuesFromInput('["a", "b"]')).toEqual(ok([["a", "b"]]))); + + test("list with identifiers", () => + expect(parseValuesFromInput("[a, b, c]")).toEqual(ok([["a", "b", "c"]]))); + + test("dictionary with number", () => + expect(parseValuesFromInput('{"a": 3}')).toEqual(ok([{ a: 3 }]))); + + test("dictionary with identifier key", () => + expect(parseValuesFromInput("{a: 3}")).toEqual(ok([{ a: 3 }]))); + + test("dictionary with identifier value", () => + expect(parseValuesFromInput('{"a ": c}')).toEqual(ok([{ "a ": "c" }]))); + + test("complex with commas", () => + expect( + parseValuesFromInput( + ' abc, 123abc, [a, b, c], abc123, 123, "123", "abc", {"a": [1, 2, 3, "f", c, {a: abcv32}]}, 1, "\\"test\\""' + ) + ).toEqual( + ok([ + "abc", + "123abc", + ["a", "b", "c"], + "abc123", + 123, + "123", + "abc", + { a: [1, 2, 3, "f", "c", { a: "abcv32" }] }, + 1, + '"test"', + ]) + )); +}); + +describe("valuesParser range matching", () => { + test("range 1", () => + expect(isRange(" -10.768 - -3.234234 ")).toBe(true)); + + test("range 2", () => expect(isRange("-.768-89.234234 ")).toBe(false)); + + test("range 3", () => expect(isRange("-.768-+.234234")).toBe(false)); + + test("range 4", () => expect(isRange("--768 - -3.234234 ")).toBe(false)); + + test("range 5", () => expect(isRange("1-1 ")).toBe(true)); + + test("range 6", () => expect(isRange(" 1---1")).toBe(false)); + + test("range 7", () => expect(isRange("-12.12 - 13")).toBe(true)); +}); + +test("valuesParser top-level resolver idDirectlyAfterNumber", () => + expect(parseValuesFromInput("3abc")).toEqual(ok(["3abc"]))); + +test("valuesParser inverse soundness", () => { + const parsed = parseValuesFromInput( + ' abc, 123abc, [a, b, c], abc123, 123, "123", "abc", {"a": [1, 2, 3, "f", c, {a: abcv32}]}, 1, "\\"test\\""' + ); + expect(parsed).toEqual( + parseValuesFromInput(serializeParsedValues(parsed.unwrapOr([]))) + ); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.ts b/apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.ts new file mode 100644 index 0000000..1cffac1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Experiments/valuesParser.ts @@ -0,0 +1,184 @@ +import { Parser } from "acorn"; +import { combine, err, ok } from "neverthrow"; + +import { ParseResult } from "./types"; + +// Used to force input to become a sequence expression. +// This is better than wrapping in a list (square brackets) +// whereby error positions can become uninformative +const modifier = { + _modifier: "_,", + len_modifier: () => modifier._modifier.length, + modify: (input: string): string => modifier._modifier + input, + unmodify: (seqExp: string): string => + seqExp.substring(modifier.len_modifier()), + unmodifyExpressionList: (list: any[]) => list.slice(1), +}; + +const wrapSubstringInQuotes = ( + input: string, + from: number, + to: number +): string => { + const checkedMin = Math.min(from, to, input.length); + const checkedMax = Math.max(from, to, 0); + return ( + input.substring(0, checkedMin) + + '"' + + input.substring(checkedMin, checkedMax) + + '"' + + input.substring(checkedMax) + ); +}; + +const numberMatch = /([+-]?\d+(?:\.\d*)?|(?:\.\d+))/; +const numberPat = new RegExp( + /^\s*/.source + numberMatch.source + /\s*$/.source +); +const rangePat = new RegExp( + /^\s*/.source + + numberMatch.source + + /\s*-\s*/.source + + numberMatch.source + + /\s*$/.source +); +const isNumber = (value: string): boolean => numberPat.test(value); +export const isRange = (value: string): boolean => rangePat.test(value); + +const specialResolvers = { + idDirectlyAfterNumber: (values: string, error: SyntaxError): string => { + const endColumnNumber = (error as any).raisedAt; // accounting for unwrap + const [part1] = values.slice(0, endColumnNumber).split(",").slice(-1); + const part2 = values.slice(endColumnNumber).split(",")[0]; + return ( + values.substring(0, endColumnNumber - part1.length) + + '"' + + (part1 + part2).trim() + + '"' + + values.substring(endColumnNumber + part2.length) + ); + }, +}; + +const specialTransformers = { + stringifyIdentifiers: (value: string): string => { + // Wrapped as a list to help distinguish between objects and block expressions + const node = (Parser.parse("[" + value + "]") as any).body[0].expression + .elements[0]; + specialTransformers + ._gatherIndentifierRanges(node) + .map(([from, to]) => { + // Account for initial open bracket + return [from - 1, to - 1]; + }) + .reverse() + .forEach(([from, to]) => { + value = wrapSubstringInQuotes(value, from, to); + }); + return value; + }, + + // Finds all places where identifiers exist and collects their positions. + // Returns an ordered list of non-overlapping ranges (care when modifying). + _gatherIndentifierRanges: (node: any): [number, number][] => { + const gathered: [number, number][] = []; + switch (node.type) { + case "Identifier": + gathered.push([node.start, node.end]); + break; + case "ArrayExpression": + node.elements.forEach((element: any) => + gathered.push( + ...specialTransformers._gatherIndentifierRanges(element) + ) + ); + break; + case "ObjectExpression": + node.properties.forEach((property: any) => { + gathered.push( + ...specialTransformers._gatherIndentifierRanges(property.key) + ); + gathered.push( + ...specialTransformers._gatherIndentifierRanges(property.value) + ); + }); + break; + } + return gathered; + }, +}; + +const convertParsedValueFromInput = (value: string): ParseResult => { + if (isNumber(value)) { + return ok(parseFloat(value)); + } + try { + const obj = JSON.parse(value); + return ok(obj); + } catch (error) {} + + return ok(value.trim()); +}; + +const parseValues = (values: string): ParseResult => { + // Assuming we're dealing with a list + let modified = modifier.modify(values); + let parsed: any[]; + let remainingRetries = 100; + while (true) { + try { + parsed = (Parser.parse(modified) as any).body[0].expression.expressions; + break; + } catch (error) { + if (remainingRetries === 0) { + console.warn( + `Exceeded number of retries for parsing values: ${values}` + ); + return err({ msg: "Invalid element data" }); + } + remainingRetries -= 1; + if ( + error instanceof SyntaxError && + error.message.includes("Identifier directly after number") + ) { + // Special case for doing a transformation like "3ar, \"a\", 3" => "\"3ar\", \"a\", 3" + modified = specialResolvers.idDirectlyAfterNumber(modified, error); + continue; + } + + if ((error as any).pos) { + const modPos = (error as any).pos as number; + const position = modPos - 1; // Account for wrap + return err({ + msg: `Invalid value at character \'${modified[modPos]}\' [${position}]`, + }); + } + + return err({ msg: "Invalid element data" }); + } + } + + const mappedModified = parsed.map((value: any) => + specialTransformers.stringifyIdentifiers( + modified.slice(value.start, value.end) + ) + ); + return ok(modifier.unmodifyExpressionList(mappedModified)); +}; + +export const parseValuesFromInput = (values: string): ParseResult => + parseValues(values).andThen((arr) => + combine(arr.map(convertParsedValueFromInput)) + ); + +export const serializeParsedValues = (values: any[]): string => + values + .map((value) => { + if (typeof value !== "number") { + // Strings should be wrapped in additional quotes + // and complex objects should be stringified too + return JSON.stringify(value); + } + return value; + }) + .join(","); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.spec.tsx new file mode 100644 index 0000000..ffa5ee3 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.spec.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ModalFormEntryDropdown } from "./ModalFormEntryDropdown"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( + {}} + />, + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.tsx new file mode 100644 index 0000000..c6ea13a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/ModalFormEntryDropdown.tsx @@ -0,0 +1,42 @@ +import React, { FC } from "react"; + +import { Dropdown } from "../../../Dropdown"; +import type { DropdownProps } from "../../../Dropdown/types"; +import { ModalFormEntry, ModalFormEntryProps } from "../ModalFormEntry"; + +type ModalFormEntryDropdownProps = Pick< + ModalFormEntryProps, + "label" | "optional" | "className" +> & + Pick< + DropdownProps, + | "options" + | "value" + | "required" + | "onChange" + | "isSearchable" + | "name" + | "placeholder" + | "isMulti" + | "isClearable" + | "creatable" + | "components" + | "menuIsOpen" // useful for debugging + | "isDisabled" + > & { creatableIsCaseInsensitive?: boolean }; + +export const ModalFormEntryDropdown: FC = ({ + label, + optional, + options, + ...rest +}) => ( + + 200} + dark + {...rest} + /> + +); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/index.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/index.ts new file mode 100644 index 0000000..982dad1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/index.ts @@ -0,0 +1,4 @@ +export { useKeywords } from "./useKeywords"; +export { useLicenses } from "./useLicenses"; +export { usePublishAs } from "./usePublishAs"; +export { useSubjects } from "./useSubjects"; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useKeywords.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useKeywords.ts new file mode 100644 index 0000000..b23bd9b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useKeywords.ts @@ -0,0 +1,30 @@ +import { useState } from "react"; +import { FunctionN } from "fp-ts/es6/function"; + +import type { Keyword } from "../../../../../util/api/types"; +import type { ReactSelectOption } from "../../../../Dropdown/types"; + +const keywordToOption: FunctionN<[Keyword], ReactSelectOption> = ({ + name, + count, +}) => ({ + value: name, + label: name, + count, +}); + +export const useKeywords = ( + keywords?: Keyword[], + existingKeywords: string[] = [] +): [ + ReactSelectOption[], + ReactSelectOption[], + (newOptions: ReactSelectOption[]) => void +] => { + const options = keywords?.map(keywordToOption) ?? []; + const [selected, setSelected] = useState( + options.filter((option) => existingKeywords.includes(option.value)) + ); + + return [options, selected, setSelected]; +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useLicenses.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useLicenses.ts new file mode 100644 index 0000000..ffe5d65 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useLicenses.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { FunctionN } from "fp-ts/es6/function"; + +import type { License } from "../../../../../util/api/types"; +import type { ReactSelectOption } from "../../../../Dropdown/types"; + +const licenceToOption: FunctionN<[License], ReactSelectOption> = ({ + id, + name, + default: isDefault, +}) => ({ + value: id, + label: isDefault ? `${name} (default)` : name, +}); + +export const useLicenses = ( + licenses?: License[], + setDefaultLicense?: Pick | null +): [ + ReactSelectOption[], + ReactSelectOption, + (option: ReactSelectOption) => void +] => { + const options = licenses?.map(licenceToOption) ?? []; + const defaultLicense = + setDefaultLicense ?? licenses?.find((license) => license.default); + const defaultOption = defaultLicense + ? options.find((option) => option.value === defaultLicense.id) + : undefined; + + if (!defaultOption) { + throw new Error("Cannot find default license"); + } + + const [selected, setSelected] = useState(defaultOption); + + return [options, selected, setSelected]; +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/usePublishAs.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/usePublishAs.ts new file mode 100644 index 0000000..99e1e43 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/usePublishAs.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { FunctionN } from "fp-ts/es6/function"; + +import type { Org } from "../../../../../util/api/types"; +import type { ReactSelectOption } from "../../../../Dropdown/types"; + +const publishAsToOptions: FunctionN<[Org], ReactSelectOption> = ({ + id, + shortname, + name, +}) => ({ + value: id, + label: name, + subLabel: shortname, +}); + +export const usePublishAs = ( + publishAs?: Org[], + currentNamespace?: string +): [ + ReactSelectOption[], + ReactSelectOption, + (setOrg: ReactSelectOption) => void +] => { + const defaultOrg = + (currentNamespace + ? publishAs?.find((org) => org.shortname === currentNamespace) + : null) ?? publishAs?.[0]; + + const options = publishAs?.map(publishAsToOptions) ?? []; + const defaultValue = options?.find( + (option) => option.subLabel === defaultOrg?.shortname + ); + + if (!defaultValue) { + throw new Error("Cannot find default publish as"); + } + + const [value, setValue] = useState(defaultValue); + + return [options, value, setValue]; +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useSubjects.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useSubjects.ts new file mode 100644 index 0000000..1a4275b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/hooks/useSubjects.ts @@ -0,0 +1,28 @@ +import { useState } from "react"; +import { FunctionN } from "fp-ts/es6/function"; + +import type { ReactSelectOption } from "../../../../Dropdown/types"; +import type { Subject } from "../../../../../util/api/types"; + +const subjectsToOptions: FunctionN<[Subject], ReactSelectOption> = ({ + id, + name, + parentChain, +}) => ({ + value: id, + label: name, + subLabel: parentChain, +}); + +export const useSubjects = ( + subjects?: Subject[] +): [ + ReactSelectOption[], + ReactSelectOption[], + (newSubjects: ReactSelectOption[]) => void +] => { + const options = subjects?.map(subjectsToOptions) ?? []; + const [selected, setSelected] = useState([]); + + return [options, selected, setSelected]; +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/index.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/index.ts new file mode 100644 index 0000000..1396525 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/Dropdown/index.ts @@ -0,0 +1,2 @@ +export { useKeywords, useLicenses, usePublishAs, useSubjects } from "./hooks"; +export { ModalFormEntryDropdown } from "./ModalFormEntryDropdown"; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.css b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.css new file mode 100644 index 0000000..47894ba --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.css @@ -0,0 +1,98 @@ +.ModalFormEntry { + display: flex; + flex-direction: column; +} + +.ModalFormEntry__Main--errorInline .ModalFormEntry__Error { + margin-left: 25px; +} + +.ModalFormEntry__Main:not(.ModalFormEntry__Main--errorInline) + .ModalFormEntry__Error { + margin-top: 10px; +} + +.ModalFormEntry__Error__Label { + color: var(--theme-red-alt); + font-size: 0.75rem; + font-family: var(--mono-font); + font-weight: bold; +} + +.ModalFormEntry__Error__Desc { + font-size: 0.875rem; +} + +.ModalFormEntry:not(:last-child) { + margin-bottom: 1.5rem; +} + +.ModalFormEntry--flex { + flex: 1; +} + +.ModalFormEntry__Main { + display: flex; + flex: 1; + max-width: 100%; +} + +.ModalFormEntry__Main:not(.ModalFormEntry__Main--errorInline) { + flex-wrap: wrap; +} + +.ModalFormEntry_Input { + display: flex; + max-width: 100%; + position: relative; + width: 100%; +} + +.ModalFormEntry__Main--errorInline .ModalFormEntry_Input { + flex: 1 0 150px; +} + +.ModalFormEntry_Input > input, +.ModalFormEntry_Input > textarea { + width: 100%; + border-radius: var(--button-border-radius); + padding: 14px 20px; + border: none; + background: rgba(255, 255, 255, 0.1); + box-sizing: border-box; + font-family: inherit; + font-size: 0.875rem; + line-height: calc(18 / 14); + color: white; +} + +.ModalFormEntry textarea { + line-height: 1.5; + resize: none; + flex: 1; + padding-top: 15px; + padding-bottom: 15px; +} + +.ModalFormEntry input::placeholder, +.ModalFormEntry textarea::placeholder { + color: var(--theme-translucent); + font-style: normal; +} + +.ModalFormEntry_Input--error input, +.ModalFormEntry_Input--error textarea { + border-color: var(--theme-red-alt); + padding-right: 48px; +} + +.ModalFormEntry_Input--error__icon { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + fill: var(--theme-red); + display: flex; + align-items: center; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.spec.tsx new file mode 100644 index 0000000..c9fb54f --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.spec.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ModalFormEntry } from "./ModalFormEntry"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render(, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.tsx new file mode 100644 index 0000000..152b700 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntry.tsx @@ -0,0 +1,68 @@ +import React, { FC, ReactNode } from "react"; +import classNames from "classnames"; +import classnames from "classnames"; + +import { IconAlert } from "../../Icon"; +import { ModalFormEntryLabel } from "./ModalFormEntryLabel"; +import { ShrinkWrap } from "../../ShrinkWrap/ShrinkWrap"; + +import "./ModalFormEntry.css"; + +export type ModalFormEntryProps = { + label?: ReactNode; + optional?: boolean; + flex?: boolean; + error?: string; + errorInline?: boolean; + className?: string; +}; + +export const ModalFormEntry: FC = ({ + label, + children, + optional = false, + flex = false, + error = null, + errorInline = true, +}) => ( + +); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.scss b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.scss new file mode 100644 index 0000000..c37c647 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.scss @@ -0,0 +1,13 @@ +.ModalFormEntry__Label { + font-size: 0.75rem; + text-transform: uppercase; + margin-bottom: 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ModalFormEntry__Optional { + color: var(--theme-translucent); + margin-left: 5px; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.tsx new file mode 100644 index 0000000..e8c68ca --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/ModalFormEntryLabel.tsx @@ -0,0 +1,13 @@ +import React, { FC } from "react"; + +import "./ModalFormEntryLabel.scss"; + +export const ModalFormEntryLabel: FC<{ optional?: boolean }> = ({ + optional, + children, +}) => ( +
    + {children}{" "} + {optional && OPTIONAL} +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.css b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.css new file mode 100644 index 0000000..63ad085 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.css @@ -0,0 +1,24 @@ +.ModalFormEntryPublishAs { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.ModalFormEntryPublishAs > * { + margin-top: 0.75rem; +} + +.ModalFormEntryPublishAs .Fancy-hindex { + font-size: 13px; + white-space: nowrap; + flex: 0 0 auto; +} + +.ModalFormEntryPublishAs .dropdown-wrapper { + flex: 1 0 0; + min-width: 80px; +} + +.ModalFormEntryPublishAs__spacer { + margin: 0.75rem 0.5rem 0; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.spec.tsx new file mode 100644 index 0000000..187698c --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.spec.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ModalFormEntryPublishAs } from "./ModalFormEntryPublishAs"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + + const user = { + subLabel: "user", + value: "", + label: "User", + }; + ReactDOM.render( + {}} + />, + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.tsx new file mode 100644 index 0000000..3f72c74 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/ModalFormEntryPublishAs.tsx @@ -0,0 +1,51 @@ +import React, { FC } from "react"; + +import { Dropdown } from "../../../Dropdown"; +import { FancyButton } from "../../../Fancy"; +import type { ReactSelectOption } from "../../../Dropdown/types"; + +import "./ModalFormEntryPublishAs.css"; + +type ModalFormEntryPublishAsProps = { + buttonLabel: string; + publishAsOptions: ReactSelectOption[]; + selectedPublishAs: ReactSelectOption; + setSelectedPublishAs: (publishAs: ReactSelectOption) => void; + disabled?: boolean; + submitDisabled?: boolean; +}; + +export const ModalFormEntryPublishAs: FC = ({ + buttonLabel, + publishAsOptions, + selectedPublishAs, + setSelectedPublishAs, + disabled = false, + submitDisabled = false, +}) => ( +
    + + {buttonLabel} + + as + {publishAsOptions.length > 1 ? ( + + ) : ( + {selectedPublishAs?.label} + )} +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/index.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/index.ts new file mode 100644 index 0000000..4ae8053 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/PublishAs/index.ts @@ -0,0 +1 @@ +export { ModalFormEntryPublishAs } from "./ModalFormEntryPublishAs"; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.spec.tsx new file mode 100644 index 0000000..85d8482 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.spec.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ModalFormEntryRequiredText } from "./ModalFormEntryRequiredText"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( + undefined} + onBlur={() => undefined} + />, + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.tsx b/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.tsx new file mode 100644 index 0000000..f94419b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/ModalFormEntryRequiredText.tsx @@ -0,0 +1,23 @@ +import React, { + forwardRef, + ForwardRefExoticComponent, + RefAttributes, + DetailedHTMLProps, + InputHTMLAttributes, +} from "react"; + +import { ModalFormEntry, ModalFormEntryProps } from "../ModalFormEntry"; + +type ModalFormEntryRequiredTextProps = Pick & { + errorMessage?: string | undefined; +} & DetailedHTMLProps, HTMLInputElement>; + +export const ModalFormEntryRequiredText: ForwardRefExoticComponent< + ModalFormEntryRequiredTextProps & RefAttributes +> = forwardRef( + ({ label, errorMessage, ...rest }, titleInputRef) => ( + + + + ) +); diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/index.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/index.ts new file mode 100644 index 0000000..ded1d6e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/RequiredText/index.ts @@ -0,0 +1 @@ +export { ModalFormEntryRequiredText } from "./ModalFormEntryRequiredText"; diff --git a/apps/sim-core/packages/core/src/components/Modal/FormEntry/index.ts b/apps/sim-core/packages/core/src/components/Modal/FormEntry/index.ts new file mode 100644 index 0000000..0eda61b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FormEntry/index.ts @@ -0,0 +1,10 @@ +export { ModalFormEntry } from "./ModalFormEntry"; +export { + ModalFormEntryDropdown, + useKeywords, + useLicenses, + usePublishAs, + useSubjects, +} from "./Dropdown"; +export { ModalFormEntryPublishAs } from "./PublishAs"; +export { ModalFormEntryRequiredText } from "./RequiredText"; diff --git a/apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.css b/apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.css new file mode 100644 index 0000000..c0176c4 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.css @@ -0,0 +1,34 @@ +.ModalFullScreen { + max-width: none; + background: transparent; + font-size: 15px; + width: var(--theme-min-width); + flex-shrink: 0; + padding: 0 30px; + position: relative; +} + +.ModalFullScreen-backdrop--light { + background-color: #e6e6e6; +} + +.ModalFullScreen, +.ModalFullScreen * { + box-sizing: border-box; +} + +.ModalFullScreen__Close { + position: fixed; + top: 2rem; + right: 2rem; + cursor: pointer; + user-select: none; +} + +.ModalFullScreen--light .ModalFullScreen__Close { + fill: var(--theme-black); +} + +.ModalFullScreen--dark .ModalFullScreen__Close { + fill: var(--theme-white); +} diff --git a/apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.tsx b/apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.tsx new file mode 100644 index 0000000..d42e165 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/FullScreen/ModalFullScreen.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; +import classNames from "classnames"; + +import { IconClose } from "../../Icon/Close"; +import { Modal, ModalProps } from "../Modal"; + +import "./ModalFullScreen.css"; + +export const ModalFullScreen: FC = ({ + backdropClassName, + modalClassName, + children, + onClose, + theme = "dark", + ...props +}) => ( + +
    + +
    + {children} +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/Modal.css b/apps/sim-core/packages/core/src/components/Modal/Modal.css new file mode 100644 index 0000000..c32725d --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Modal.css @@ -0,0 +1,42 @@ +.Modal-container { + position: fixed; + top: 0; + z-index: var(--modal-z); + + width: 100%; + height: 100%; + min-width: var(--theme-min-width); + + display: flex; + align-items: center; + justify-content: center; + + overflow: scroll; +} + +.Modal-backdrop { + background-color: rgba(0, 0, 0, 0.9); + + width: 100%; + height: 100%; + + position: fixed; + top: 0; + left: 0; + z-index: var(--modal-container-z); +} + +.Modal { + --max-width-pc-default: 90%; + background-color: rgba(17, 17, 17, 0.9); + /** min isn't supported in all our browsers, so need to have a fallback */ + max-width: var(--max-width-pc, var(--max-width-pc-default)); + max-width: min( + var(--max-width-px, 1220px), + var(--max-width-pc, var(--max-width-pc-default)) + ); +} + +.react-tiny-popover-container.Modal__Tooltip { + z-index: calc(var(--modal-z) + 1); +} diff --git a/apps/sim-core/packages/core/src/components/Modal/Modal.tsx b/apps/sim-core/packages/core/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..e0a95ab --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/Modal.tsx @@ -0,0 +1,85 @@ +import React, { + forwardRef, + HTMLProps, + ReactNode, + useEffect, + MouseEvent, +} from "react"; +import classNames from "classnames"; + +import "./Modal.css"; + +export type ModalProps = { + onClose?: () => void; + modalClassName?: string; + backdropClassName?: string; + esc?: boolean; + backdropExit?: boolean; + containerClassName?: string; + children?: ReactNode | null; + onClick?: HTMLProps["onClick"]; +}; + +export const Modal = forwardRef( + ( + { + onClose, + modalClassName = "", + backdropClassName = "", + children, + esc = true, + backdropExit = true, + containerClassName, + onClick, + }, + ref + ) => { + useEffect(() => { + if (esc) { + function handler(evt: KeyboardEvent) { + if (evt.key === "Escape") { + evt.preventDefault(); + onClose?.(); + } + } + + window.addEventListener("keydown", handler); + + return () => { + window.removeEventListener("keydown", handler); + }; + } + }, [esc, onClose]); + + const content = ( +
    { + evt.stopPropagation(); + onClick?.(evt); + }} + onMouseDown={(evt) => evt.stopPropagation()} + className={`Modal ${modalClassName}`} + ref={ref} + > + {children} +
    + ); + + return ( + <> +
    { + evt.stopPropagation(); + if (backdropExit && evt.button === 0) { + onClose?.(); + } + }} + > + {content} +
    +
    + + ); + } +); diff --git a/apps/sim-core/packages/core/src/components/Modal/ModalExit.scss b/apps/sim-core/packages/core/src/components/Modal/ModalExit.scss new file mode 100644 index 0000000..2635c8b --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/ModalExit.scss @@ -0,0 +1,25 @@ +.ModalExit { + position: absolute; + top: 25px; + right: 25px; + background: none; + border: none; + padding: 0; + margin: 0; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); + fill: currentColor; + font-size: 12px; + display: flex; + align-items: center; + z-index: 1; + + .Icon { + margin-left: 6px; + } + + &:hover { + color: white; + fill: white; + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/ModalExit.tsx b/apps/sim-core/packages/core/src/components/Modal/ModalExit.tsx new file mode 100644 index 0000000..5ebbf47 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/ModalExit.tsx @@ -0,0 +1,17 @@ +import React, { FC } from "react"; + +import { IconClose } from "../Icon"; + +import "./ModalExit.scss"; + +export const ModalExit: FC<{ onClose: VoidFunction }> = ({ onClose }) => ( + +); diff --git a/apps/sim-core/packages/core/src/components/Modal/ModalForm.scss b/apps/sim-core/packages/core/src/components/Modal/ModalForm.scss new file mode 100644 index 0000000..c92a8d7 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/ModalForm.scss @@ -0,0 +1,23 @@ +.ModalForm__Fieldset { + border: 0; + padding: 0; + margin: 0; + display: flex; + position: relative; +} + +.ModalForm__Fieldset .ModalFormEntry__Input, +.ModalForm__Fieldset .Fancy, +.ModalForm__Fieldset .VersionPicker__item { + transition: opacity 0.2s ease; +} + +.ModalForm__Fieldset:disabled .ModalFormEntry_Input, +.ModalForm__Fieldset:disabled .Fancy, +.ModalForm__Fieldset:disabled .VersionPicker__item, +.ModalForm__Fieldset .ModalFormEntry_Input:disabled, +.ModalForm__Fieldset .Fancy:disabled, +.ModalForm__Fieldset .VersionPicker__item:disabled { + opacity: 0.5; + cursor: default !important; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/ModalForm.tsx b/apps/sim-core/packages/core/src/components/Modal/ModalForm.tsx new file mode 100644 index 0000000..f875c75 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/ModalForm.tsx @@ -0,0 +1,20 @@ +import React, { FC } from "react"; + +import "./ModalForm.scss"; + +export const ModalForm: FC<{ + onSubmit: () => Promise; + disabled: boolean | undefined; +}> = ({ onSubmit, disabled, children }) => ( +
    { + event.preventDefault(); + event.stopPropagation(); + onSubmit(); + }} + > +
    + {children} +
    +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.scss b/apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.scss new file mode 100644 index 0000000..cdeaa4e --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.scss @@ -0,0 +1,30 @@ +.ModalInfoBox { + display: flex; + padding: 10px; + border: 1px solid #202020; + border-radius: 3px; + color: #8e8e8e; + align-items: center; + user-select: none; + margin-top: 10px; + box-sizing: border-box; + + .Icon { + margin-right: 7px; + fill: currentColor; + flex-shrink: 0; + } + + p { + font-size: 12px; + line-height: 1.1; + padding: 2px; + margin: 0; + } +} + +.ModalInfoBox--warning { + > .Icon { + fill: var(--theme-yellow-alt); + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.tsx b/apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.tsx new file mode 100644 index 0000000..e0108ad --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/ModalInfoBox.tsx @@ -0,0 +1,22 @@ +import React, { FC } from "react"; +import classNames from "classnames"; + +import { IconAlertOutline, IconInformationOutline } from "../Icon"; + +import "./ModalInfoBox.scss"; + +export const ModalInfoBox: FC<{ + type?: "info" | "warning"; + className?: string; +}> = ({ type = "info", children, className }) => ( +
    + {type === "info" ? ( + + ) : ( + + )} +

    {children}

    +
    +); diff --git a/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.css b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.css new file mode 100644 index 0000000..b60bf45 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.css @@ -0,0 +1,114 @@ +/*noinspection CssOverwrittenProperties*/ +.ModalNameBehavior { + /** + * @todo remove this fallback when all supported browsers support clamp + * @see https://docs.google.com/spreadsheets/d/1pCjtbvxIgnbzIbgfrJuYIk6TLHhLhqIxNAA2Iord1gE/edit#gid=319934737&range=A3 + */ + min-width: 60%; + min-width: min(60%, 600px); + + border: 1px solid var(--theme-dark-border); + border-radius: 7px; +} + +.ModalNameBehavior--error { + border-color: var(--theme-red); +} + +.ModalNameBehavior--form { + display: flex; +} + +.ModalNameBehavior--input__wrapper { + flex: 1 0 auto; + display: flex; + align-items: center; + background-color: var(--theme-dark); + border-top-left-radius: var(--button-border-radius); + border-bottom-left-radius: var(--button-border-radius); + padding: 0.5rem 1rem; + position: relative; +} + +.ModalNameBehavior--input { + max-width: 70vw; +} + +.ModalNameBehavior--input { + min-width: 8px; + color: var(--theme-white); + padding: 0; + margin: 0; +} + +.ModalNameBehavior--input::placeholder { + color: var(--theme-translucent); + font-style: normal; +} + +.ModalNameBehavior__error-message { + font-size: 0.75rem; + padding-left: 20px; + white-space: nowrap; + color: var(--theme-red-alt); + text-transform: uppercase; +} + +.ModalNameBehavior__error-message--tooltip { + position: absolute; + display: flex; + align-items: center; + top: 0; + bottom: 0; + right: 0; + padding-right: 1rem; + background: linear-gradient(to right, transparent, var(--theme-dark) 20px); + padding-left: 40px; + fill: var(--theme-red); +} + +.ModalNameBehavior .dropdown-wrapper { + width: 5.32rem; + flex: 0 0 auto; +} + +.ModalNameBehavior .dropdown-wrapper .react-select__control { + border-radius: 0; + background-color: var(--theme-dark-hover) !important; +} + +.ModalNameBehavior + .dropdown-wrapper + .react-select-container.dark + .react-select__control { + border-width: 0 1px; +} + +.ModalNameBehavior .dropdown-wrapper .react-select-container, +.ModalNameBehavior .dropdown-wrapper .react-select__control, +.ModalNameBehavior .dropdown-wrapper .react-select__value-container { + height: 100%; +} + +.ModalNameBehavior--input, +.ModalNameBehavior .dropdown-wrapper .react-select__single-value { + font-size: 1.375rem; + line-height: 1.5; +} + +.ModalNameBehavior--submit { + border: 0; + border-top-right-radius: var(--button-border-radius); + border-bottom-right-radius: var(--button-border-radius); + + display: flex; + align-items: center; + font-size: 0.875rem; + padding: 0 1rem 0 1.25rem; + text-transform: uppercase; +} + +.ModalNameBehavior--submit .Icon { + fill: currentColor; + margin-left: 0.5rem; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.spec.tsx b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.spec.tsx new file mode 100644 index 0000000..ff3d71a --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.spec.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import { ModalNameBehavior } from "./ModalNameBehavior"; + +it("renders without crashing", () => { + const div = document.createElement("div"); + ReactDOM.render( + {}} + onCancel={() => {}} + name="some_name" + onNameChange={() => {}} + errorMessage="" + languageOptions={[]} + selectedLanguage={{ value: "", label: "" }} + onSelectedLanguageChange={() => {}} + action="Create" + placeholder="Name your new file" + />, + div + ); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.tsx b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.tsx new file mode 100644 index 0000000..4d288d1 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/ModalNameBehavior.tsx @@ -0,0 +1,148 @@ +import React, { + FC, + FormEventHandler, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import classNames from "classnames"; + +import { Dropdown } from "../../Dropdown"; +import { IconAlert } from "../../Icon/Alert"; +import { IconKeyboardReturn } from "../../Icon/KeyboardReturn"; +import { Modal } from "../Modal"; +import type { ReactSelectOption } from "../../Dropdown/types"; +import { ResizingInputText } from "../../ResizingInputText"; +import { useResizeObserver } from "../../../hooks/useResizeObserver/useResizeObserver"; + +import "./ModalNameBehavior.css"; + +type ModalNameBehaviorProps = { + errorMessage: string | null; + languageOptions: ReactSelectOption[]; + name: string; + onNameChange: (name: string) => void; + onCancel: () => void; + onSubmit: FormEventHandler; + selectedLanguage: ReactSelectOption; + onSelectedLanguageChange: (language: ReactSelectOption) => void; + action: string; + placeholder: string; +}; + +export const ModalNameBehavior: FC = ({ + onSubmit, + onCancel, + name, + onNameChange, + errorMessage, + languageOptions, + selectedLanguage, + onSelectedLanguageChange, + action, + placeholder, +}) => { + const inputRef = useRef(null); + const errorRef = useRef(null); + const [tooltipError, setTooltipError] = useState(false); + + const tooltipErrorRef = useRef(tooltipError); + tooltipErrorRef.current = tooltipError; + + useEffect(() => { + inputRef.current?.focus(); + }); + + const resize = useCallback(() => { + if (tooltipErrorRef.current || !inputRef.current) { + return; + } + + const inputRect = inputRef.current.getBoundingClientRect(); + const errorRect = errorRef.current?.getBoundingClientRect(); + + setTooltipError( + inputRect.width + (errorRect?.width ?? 0) > window.innerWidth * 0.7 + ); + }, []); + + useEffect(() => { + window.addEventListener("resize", resize); + + return () => window.removeEventListener("resize", resize); + }, [resize]); + + useLayoutEffect(() => { + if (tooltipErrorRef.current) { + setTooltipError(false); + resize(); + } + }, [errorMessage, resize]); + + const setErrorObserver = useResizeObserver(resize); + + const setErrorRef = useCallback( + (node: HTMLDivElement) => { + errorRef.current = node; + setErrorObserver(node); + }, + [setErrorObserver] + ); + + return ( + +
    +
    inputRef.current?.focus()} + > + onNameChange(evt.target.value)} + onResize={resize} + /> + {errorMessage ? ( +
    inputRef.current?.focus()} + > + {tooltipError ? : errorMessage} +
    + ) : null} +
    + + + +
    + ); +}; + +// // @ts-ignore +// ModalNameBehavior.whyDidYouRender = { +// customName: "ModalNameBehavior" +// }; diff --git a/apps/sim-core/packages/core/src/components/Modal/NameBehavior/index.ts b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/index.ts new file mode 100644 index 0000000..6b9e489 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NameBehavior/index.ts @@ -0,0 +1 @@ +export { ModalNameBehavior } from "./ModalNameBehavior"; diff --git a/apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.scss b/apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.scss new file mode 100644 index 0000000..77ad420 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.scss @@ -0,0 +1,75 @@ +.ModalNewDatasetContainer { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: var(--modal-container-z); +} + +.ModalNewDataset { + padding: 0; + overflow: hidden; +} + +.ModalNewDataset__Root { + min-height: 380px; + min-width: 820px; + display: flex; + align-items: center; + justify-content: center; + padding: var(--big-modal-padding); + box-sizing: border-box; + position: relative; + + &:focus, + &:active { + outline: none; + } +} + +.ModalNewDataset__Root--notDisabled { + cursor: pointer; +} + +.ModalNewDataset__Root--dragging { + background-color: var(--theme-blue-translucent); +} + +.ModalNewDataset__Content { + color: white; + fill: white; + text-align: center; + + h3, + p { + margin: 0; + padding: 0; + line-height: 1.3; + } + + h3 { + font-size: 18px; + font-weight: bold; + } + + p { + font-size: 14px; + } +} + +.ModalNewDataset__Errored { + font-size: 8px; + opacity: 0.66; + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + + .Icon { + margin-right: 3px; + flex-shrink: 0; + } +} diff --git a/apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.tsx b/apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.tsx new file mode 100644 index 0000000..f4834d0 --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NewDataset/ModalNewDataset.tsx @@ -0,0 +1,95 @@ +import React, { FC, useCallback, useState } from "react"; +import { useDispatch } from "react-redux"; +import { useDropzone } from "react-dropzone"; +import classNames from "classnames"; + +import { AppDispatch } from "../../../features/types"; +import { BigModal } from "../BigModal"; +import { IconAlert, IconSpinner } from "../../Icon"; +import { IconUpload } from "../../Icon/Upload"; +import { createDataset } from "../../../features/files/slice"; + +import "./ModalNewDataset.scss"; + +export const ModalNewDataset: FC<{ onClose: VoidFunction }> = ({ onClose }) => { + const dispatch = useDispatch(); + + const [state, setState] = useState<"uploading" | "failed" | "initial">( + "initial" + ); + + const onDrop = useCallback( + async (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + + if (!file) { + throw new Error("Could not find file"); + } + + setState("uploading"); + + try { + await dispatch(createDataset(file)); + onClose(); + } catch (err) { + console.error("Uploading failed", err); + setState("failed"); + } + }, + [onClose, dispatch] + ); + + const uploading = state === "uploading"; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + maxFiles: 1, + multiple: false, + disabled: uploading, + accept: ".csv, .json", + }); + + const { + className: rootClassName, + onClick: rootOnClick, + ...rootProps + } = getRootProps(); + + return ( +
    + + +
    +
    + {uploading ? ( + + ) : ( + <> + +

    Click here to upload

    +

    or drop a file on the screen

    + {state === "failed" ? ( +

    + Uploading failed. Please try again. +

    + ) : null} + + )} +
    +
    +
    +
    + ); +}; diff --git a/apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.css b/apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.css new file mode 100644 index 0000000..da6bbbb --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.css @@ -0,0 +1,174 @@ +.ModalNewProject fieldset { + border: 0; + margin: 0; + padding: 0; +} + +.ModalNewProject__Field { + margin-bottom: 30px; + + --error-color: var(--theme-red); +} + +.ModalNewProject__Field:not(:hover):not(.ModalNewProject__Field--focused):not(.ModalNewProject__Field--showTip) + .ModalNewProject__Field__Tip:not(.ModalNewProject__Field__Tip--withError) { + visibility: hidden; +} + +.ModalNewProject__Field__Tip { + flex: 1; + width: 0; + line-height: 1.3; + align-self: stretch; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.ModalNewProject__Field__Tip > :first-child { + width: 100%; +} + +.ModalNewProject__Field__Top { + display: block; + margin-bottom: 10px; +} + +.ModalNewProject__Field__Top label { + font-weight: bold; + margin-right: 10px; +} + +.ModalNewProject__Field__Bottom { + display: flex; + align-items: center; + min-height: 65px; + flex: 1; +} + +.ModalNewProject__Field__Error { + background-color: var(--theme-red-translucent); + color: white; + border-radius: 3px; + padding: 3px 6px; + margin-top: 2px; +} + +.ModalNewProject__Input { + border: 1px solid #444; + border-radius: 16px; + display: inline-flex; + margin-right: 25px; + align-items: center; + font-size: 18px; + overflow: hidden; + transition: border-color 0.2s ease; + position: relative; + background: #080808; + + --padding-vertical: 16px; + --padding-horizontal: 22px; +} + +.ModalNewProject__Field--focused .ModalNewProject__Input { + border-color: #666; +} + +.ModalNewProject__Field--error .ModalNewProject__Input { + border-color: var(--error-color); +} + +.ModalNewProject__Field--focused.ModalNewProject__Field--error + .ModalNewProject__Input { + border-color: var(--theme-red-alt); +} + +.ModalNewProject__Input--text { + width: 500px; +} + +.ModalNewProject__Input input, +.ModalNewProject__Input select { + flex: 1; + width: 0; + background: transparent; + border: none; + font-size: inherit; +} + +.ModalNewProject fieldset:disabled .ModalNewProject__Input, +.ModalNewProject fieldset:disabled .Fancy, +.ModalNewProject .Fancy:disabled, +.ModalNewProject__Input__FakeSelect.Select--disabled { + opacity: 0.5; + pointer-events: none; +} + +.ModalNewProject fieldset .ModalNewProject__Input, +.ModalNewProject fieldset .Fancy { + transition: opacity 0.2s ease; +} + +.ModalNewProject__Input > :first-child { + padding-left: var(--padding-horizontal); +} + +.ModalNewProject__Input > :last-child { + padding-right: calc(var(--padding-horizontal) / 2); +} + +.ModalNewProject__Input > input:last-child { + padding-right: var(--padding-horizontal); +} + +.ModalNewProject__Input > * { + padding-top: var(--padding-vertical); + padding-bottom: var(--padding-vertical); +} + +.ModalNewProject__Input input::placeholder { + font-style: normal; + color: rgba(255, 255, 255, 0.33); +} + +.ModalNewProject__Input__FakeSelect { + color: rgba(255, 255, 255, 0.33); +} + +.ModalNewProject__Input__FakeSelect .Icon { + fill: white; +} + +.ModalNewProject__Input__FakeSelect:not(:last-child) { + background: #111111; + border-right: 1px solid rgba(255, 255, 255, 0.1); + padding-right: 12px; +} + +.ModalNewProject__Input__FakeSelect .Select__Value { + color: white; + fill: white; + display: inline-flex; + align-items: center; +} + +.ModalNewProject__Input__FakeSelect .Select__Value .Icon { + margin-right: 5px; +} + +.ModalNewProject__Input__FakeSelect.Select--focused .Select__Value { + border-radius: 3px; + background-color: rgba(255, 255, 255, 0.2); +} + +.ModalNewProject__Input__FakeSelect + input { + padding-left: 15px; +} + +.ModalNewProject__Submit { + margin-top: 60px; +} + +.ModalNewProject__Submit .Fancy-label * { + text-transform: inherit !important; +} diff --git a/apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.tsx b/apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.tsx new file mode 100644 index 0000000..14848ac --- /dev/null +++ b/apps/sim-core/packages/core/src/components/Modal/NewProject/ModalNewProject.tsx @@ -0,0 +1,264 @@ +import React, { + FC, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useForm } from "react-hook-form"; + +import { FancyButton } from "../../Fancy/Button"; +import { IconEarth } from "../../Icon/Earth"; +import { IconLock } from "../../Icon/Lock"; +import { ModalFullScreen } from "../FullScreen/ModalFullScreen"; +import { NewProjectField } from "./NewProjectField"; +import { NewProjectModalValues } from "./types"; +import { Org } from "../../../util/api/types"; +import { ProjectVisibility } from "../../../features/project/types"; +import { Select } from "../../Inputs/Select/Select"; +import { TipWithError } from "./TipWithError"; +import { USER_ORG_VALUE, namespacePrefix, useOrgs } from "./utils"; +import { + handleQueryCodeErrors, + validateName, + validatePath, +} from "../../../features/project/validation"; +import { slugify } from "../../../routes"; +import { useFatalError } from "../../ErrorBoundary/ErrorBoundary"; +import { useSafeOnClose } from "../../../hooks/useSafeOnClose"; + +import "./ModalNewProject.css"; + +const getOrgValue = (org?: Org | null) => + org ? (org.id === "user" ? USER_ORG_VALUE : org.shortname) : null; + +export const ModalNewProject: FC<{ + onCancel: VoidFunction; + onSubmit: (values: NewProjectModalValues) => Promise; + defaultName?: string; + action: ReactNode; + defaultVisibility?: ProjectVisibility; + visibilityDisabled?: boolean; + defaultNamespace?: string; +}> = ({ + onCancel, + onSubmit, + action, + defaultVisibility = "public", + visibilityDisabled, + defaultName = "", + defaultNamespace, +}) => { + const fatalError = useFatalError(); + const orgs = useOrgs(); + const nameRef = useRef(null); + const [nameFocused, setNameFocused] = useState(false); + const [namespaceFocused, setNamespaceFocused] = useState(false); + const [pathFocused, setPathFocused] = useState(false); + const [visibilityFocused, setVisibilityFocused] = useState(false); + + const defaultOrg = defaultNamespace + ? orgs.find((org) => org.shortname === defaultNamespace) + : null; + + const { + register, + handleSubmit, + watch, + formState: { isDirty, isSubmitting, dirtyFields, errors }, + setValue, + setError, + } = useForm({ + defaultValues: { + namespace: getOrgValue(defaultOrg) ?? USER_ORG_VALUE, + visibility: defaultVisibility, + name: defaultName, + path: slugify(defaultName), + }, + shouldFocusError: true, + mode: "onBlur", + }); + + const setNameRef = useCallback( + (node: HTMLInputElement | null) => { + nameRef.current = node; + register(node, { + validate: validateName, + }); + }, + [register] + ); + + useEffect(() => { + nameRef.current?.focus(); + nameRef.current?.select(); + }, []); + + const safeOnCancel = useSafeOnClose( + Object.keys(dirtyFields).length === 0, + !isSubmitting, + onCancel + ); + + const namespace = watch("namespace"); + const visibility = watch("visibility"); + const privateLabel = + namespace === USER_ORG_VALUE ? "Private" : "Private to org"; + + const createModel = async (values: NewProjectModalValues) => { + try { + await handleQueryCodeErrors(values, setError, async () => { + await onSubmit({ + ...values, + namespace: + values.namespace === USER_ORG_VALUE ? "" : values.namespace, + }); + }); + } catch (err) { + fatalError(err); + } + }; + + return ( + +
    +
    + +
    + { + setNameFocused(true); + }} + onBlur={() => { + setNameFocused(false); + }} + onChange={(evt) => { + if (!dirtyFields.path) { + setValue("path", slugify(evt.target.value), { + shouldValidate: !!errors.path, + }); + } + }} + ref={setNameRef} + /> +
    + +
    + +
    + { + setPathFocused(true); + }} + onBlur={() => { + setPathFocused(false); + }} + ref={register({ validate: validatePath })} + /> +
    + +
    + +
    +