diff --git a/.eslintrc b/.eslintrc index 49a3684bb..201d888e9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,6 +14,7 @@ "no-nested-ternary": 0, "no-param-reassign": 0, "no-return-assign": 0, + "no-restricted-syntax": 0, "react/jsx-filename-extension": 0, "react/jsx-props-no-spreading": 0, "react/destructuring-assignment": 0, @@ -21,6 +22,12 @@ "react/no-danger": 0, "no-unused-vars": 1, "react/button-has-type": 1, - "no-underscore-dangle": ["warn", { "allow": ["_kmq", "_kmk"] }] + "no-underscore-dangle": ["warn", { "allow": ["_kmq", "_kmk"] }], + "jsx-a11y/label-has-associated-control": 0, + "react/jsx-no-bind": 0, + "react/no-unstable-nested-components": [ + "warn", + {"allowAsProps": true } + ] } } diff --git a/.github/workflows/publish-stage.yaml b/.github/workflows/publish-stage.yaml index 7613ef371..43024b489 100644 --- a/.github/workflows/publish-stage.yaml +++ b/.github/workflows/publish-stage.yaml @@ -72,7 +72,7 @@ jobs: #!/bin/bash yarn - yarn lint + yarn lint-prod yarn build:stage - name: Clean Remote Directory env: diff --git a/.gitignore b/.gitignore index 095b8f392..934e75f19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.idea /src/config/index.js # dependencies diff --git a/package.json b/package.json index 14b154e69..65b190f97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harperdb-studio", - "version": "4.2.11", + "version": "4.3.0", "description": "A UI for HarperDB", "deploymentUrl": "studio.harperdb.io", "private": true, @@ -11,7 +11,8 @@ "build:dev": "PUBLIC_URL=https://d2uv4fa0aeja0t.cloudfront.net react-scripts build", "build:stage": "GENERATE_SOURCEMAP=false PUBLIC_URL=https://dbjxbnqel2bw9.cloudfront.net react-scripts build", "build:prod": "GENERATE_SOURCEMAP=false PUBLIC_URL=https://ds5zz9rfvzuta.cloudfront.net react-scripts build", - "lint": "eslint --fix src && npx stylelint --fix \"src/**/*.scss\"" + "lint-dev": "eslint --fix src && npx stylelint --fix \"src/**/*.scss\"", + "lint-prod": "eslint --fix src" }, "dependencies": { "@monaco-editor/react": "^4.2.0", @@ -43,8 +44,10 @@ "react-toggle": "^4.1.2", "reactstrap": "^9.0.0", "use-async-effect": "^2.2.3", + "use-debounce": "^9.0.4", "use-interval": "^1.3.0", "use-persisted-state": "^0.3.3", + "uuid": "^9.0.0", "whatwg-fetch": "^3.6.2" }, "engines": { diff --git a/src/app.scss b/src/app.scss index 068d6d6f5..1f7e08f83 100644 --- a/src/app.scss +++ b/src/app.scss @@ -58,6 +58,7 @@ @import 'assets/styles/components/copyable-text-holder'; @import 'assets/styles/components/badge'; @import 'assets/styles/components/code-editor'; +@import 'assets/styles/components/web-ide'; // override styles @import 'assets/styles/overrides/dark'; diff --git a/src/assets/styles/components/_react-select.scss b/src/assets/styles/components/_react-select.scss index 6b5dd2796..c4871b8c5 100755 --- a/src/assets/styles/components/_react-select.scss +++ b/src/assets/styles/components/_react-select.scss @@ -26,6 +26,15 @@ max-height: 36px; padding-bottom: 0 !important; padding-top: 0 !important; + + .react-select__input-container { + .react-select__input { + background: transparent !important; + border: transparent !important; + outline-color: transparent !important; + } + } + } } diff --git a/src/assets/styles/components/_web-ide.scss b/src/assets/styles/components/_web-ide.scss new file mode 100644 index 000000000..4ca091ec3 --- /dev/null +++ b/src/assets/styles/components/_web-ide.scss @@ -0,0 +1,987 @@ +.dark { + .no-projects { + .docs-link { + color: #ee81ee !important; + } + } + + color: white; + + .editor.current-file-path { + border-bottom: 1px solid transparent; + } + + .default-window-option { + span { + color: white; + } + + } + + .name-input { + + .validation-message { + color: $color-danger; + } + + .name-input-container { + input.invalid { + border: 1px solid $color-danger !important; + outline-color: $color-danger !important; + } + + .invalid-project-name { + color: $color-danger; + } + } + } + + + .package-install-window { + background: rgb(17, 17, 17); + } + + .file.folder-selected { + background: rgba(200, 200, 200, 0.2) !important; + } + + .file-menu, + .editor-menu { + border-bottom: 1px solid rgb(60, 60, 60) !important; + } + + .file-browser button > span { + color: white !important; + } + + .file-browser .file.file-selected > span { + color: #ee81ee !important; + } + + + button[disabled]:hover, + button[disabled] { + color: #5e5e5e !important; + } + + +} + +.purple { + + + .default-window-option span { + color: white !important; + } + + .editor-window .default-window .default-window-container { + background: $color-pureblack !important; + color: white; + } + + .no-projects { + color: white; + + .docs-link { + color: #ee81ee !important; + } + } + + color: white; + + .name-input { + .name-input-container { + input.invalid:focus { + border: 1px solid #ea4c89 !important; + outline-color: #ea4c89 !important; + } + } + .validation-message { + margin-top: 8px; + color: $color-danger; + } + + & > input.invalid { + border: 1px solid $color-danger !important; + outline-color: $color-danger !important; + } + + + + .invalid-text { + color: red !important; + } + + .invalid-project-name { + color: $color-danger; + } + } + + .editor-window .cancel-button { + color: rgb(105, 105, 105);; + } + .editor-window .cancel-button:hover { + color: white; + } + + .package-install-window { + background: white; + } + + .editor.current-file-path { + border-bottom: 1px solid lightgray; + } + + color: rgb(105, 105, 105); + + + .default-window-option { + span { + color: rgb(105, 105, 105); + } + } + + .cancel-button { + color: #5c636a; + } + + .file.folder-selected { + background: rgba(200, 200, 200, 0.2) !important; + } + + .editor-window { + display: flex; + flex-direction: column; + + .current-file-path { + background: rgb(26, 26, 26); + } + + /* + .root { + display: grid; + grid-template-columns: 150px minmax(0px, auto); + height: 100%; + } + */ + // background: transparent !important; + } + + .file-menu, + .editor-menu { + border-bottom: 1px solid darkgray !important; + } + + .save-code[disabled] { + color: #c2c2c2 !important; + } + + .file-browser button { + color: lightgray; + } + + .delete-file-icon[disabled] { + // color: #5e5e5e !important; + color: #c2c2c2; + } + + +} + + +.light { + + .name-input { + + .validation-message { + color: $color-danger; + } + + .name-input-container { + input.invalid { + border: 1px solid $color-danger !important; + outline-color: $color-danger !important; + } + + .invalid-project-name { + color: $color-danger; + } + } + } + + + .no-projects { + color: rgb(105, 105, 105); + } + + color: rgb(105, 105, 105); + + .package-install-window { + background: white; + } + + .editor.current-file-path { + border-bottom: 1px solid lightgray; + } + + .restart-instance-on-save-text { + color: rgb(240, 240, 240) !important; + } + + .file-menu button[disabled], + .editor-menu button[disabled] { + color: rgb(240, 240, 240) !important; + // #5e5e5e; + } + + .file-browser { + background: #f9f9f9; + } + + .file.folder-selected { + background: rgba(200, 200, 200, 0.2) !important; + //background: lightgray !important; + } + + .file-menu, + .editor-menu { + border-bottom: 1px solid lightgray !important; + } + + + .file-menu button { + color: #b1b1b1; + } + + .file-icon.filetype-unknown { + color: #b1b1b1 !important; + } + + .editor-window button:hover { + color: white; + //rgb(105,105,105); + } + + .editor-window button { + color: rgb(105, 105, 105); + } + + .install-package-icon { + color: #f24242 !important; + } + + .add-project-icon { + color: rgb(25, 168, 148) !important; + } + + .file-menu { + color: rgb(105, 105, 105); + } + + .default-window-option { + span { + color: rgb(105, 105, 105); + } + } +} + +.web-ide { + + + .cancel-button { + margin-left: 10px; + } + // general stuff + height: 75vh; + overflow: hidden; + + ul, li { + list-style-type: none; + } + + label, button { + white-space: nowrap !important; + } + + input { + padding-left: 8px; + padding-right: 8px; + } + + .add-project-icon:hover, + .default-window .add-project-icon { + color: rgb(25, 168, 148); // always green + } + + + li { + margin: 0 5px 5px 0; + padding: 0 5px 0 0; + } + + .default-window .install-package-icon, + .install-package-icon:hover { + color: #f24242; + } + + .menu-pointer-icon { + color: rgb(143, 141, 255); + } + + .add-folder-icon:hover, + .delete-folder-icon:hover { + color: rgb(143, 141, 255); + } + + .delete-file-icon:hover { + // color: #ee81ee !important; + } + + + .install-package-icon, + .add-folder-icon, + .add-project-icon, + .upload-file-icon, + .delete-folder-icon, + .delete-file-icon, + .add-file-icon { + background: transparent; + border: none; + color: white; + margin: 0; + outline: none !important; + padding: 0; + } + + .file-browser-scroll-container { + height: 100%; + overflow-y: auto; + + } + + + + // top-level element (left-side) + .file-browser-outer-container { + height: 100%; + overflow-y: auto; + } + + .file-browser-inner-container { + + padding: 20px; + + .file-menu { + color: white; + + display: flex; + flex-direction: row; + margin-bottom: 20px; + margin-top: 0; + padding-bottom: 0; + padding-top: 0; + + .file-menu-item { + align-items: flex-end; + display: flex; + flex-direction: row; + } + } + + + .file-browser { + + margin-left: 0; + padding: 20px; + padding-left: 0; + + .folder-container { + margin-bottom: 0; + padding-bottom: 0; + padding-left: 0; + + + .rename-file, + .deploy-project { + margin-left: 8px; + } + + .rename-file { + color: transparent; + } + + .file { + &.file-selected { + color: #ee81ee; + //background: #191919 !important; + // background: #4a4a4a !important; + //border: 1px solid rgb(120,120,120);; + } + + &.folder-selected { + //background: #2c2c2c !important; + } + } + + .package.package-selected { + color: #f24242; + } + + .project-icon { + color: rgb(25, 168, 148); + + } + + .package-icon { + color: #f24242; + } + + .file, + .package { + + background: transparent; + border: 1px solid transparent; + list-style-type: none; + margin: 0; + outline: none !important; // TODO: fix this because it is not accessible; style differently + padding: 3px; + white-space: nowrap; + + &.selected { + } + + .package-text, + .filename-text { + padding-left: 5px; + } + + .file-icon.filetype-js { + color: #f7df1e; + } + + .file-icon.filetype-yaml { + color: #c4c4ff; + } + + .file-icon.filetype-unknown { + color: lightgray; + } + + .folder-icon { + color: rgb(143 141 255); + } + } + + .folder { + padding-left: 15px; + } + + + .folder.folder-contents-closed { + display: none; + + } + + .folder.folder-contents-open { + display: block; + } + } + + + } + } + + // top-level element: editor window (right side) + // contains code editor and project management windows + .editor-window-container { + display: flex; + flex-direction: column; + height: 100%; + + padding: 20px; + + .editor-menu { + align-items: baseline; + border-bottom: 1px solid #2e2e2e; + color: white; + display: flex; + flex-direction: row; + justify-content: left; + margin-bottom: 20px; + margin-top: 0; + padding-bottom: 0; + padding-top: 0; + + & > li { + margin: 0 5px 5px 0; + padding: 0 5px 0 0; + } + + button[disabled], + button[disabled]:hover { + color: #5e5e5e; + } + + .restart-instance-after-save, + .restart-instance { + background: transparent; + border: 1px solid transparent; + display: flex; + flex-direction: row; + margin: 0; + outline: none !important; + padding: 3px; + + & > i { + color: #ee81ee; + } + } + + .save-code { + background: transparent; + border: none; + color: #ee81ee; + cursor: pointer; + margin: 0; + outline: none !important; + padding: 0; + } + + .save-code[disabled] { + color: #5e5e5e; + cursor: default; + } + } + + .editor-window { + align-items: center; + display: flex; + flex-direction: column; + + height: 100%; + justify-content: center; + width: 100%; + + + .name-input { + display: flex; + flex-direction: column; + width: 50%; + + + .validation-message { + margin-top: 8px; + } + + .name-input-container { + display: flex; + + flex-direction: row; + align-items: center; + + .invalid-project-name { + margin-left: 15px; + } + + .hidden { + visibility: hidden; + } + } + + .name-input-buttons-container { + padding-top: 10px; + } + } + + .delete-folder-window-title, + .delete-file-window-title, + .delete-package-window-title, + .folder-to-delete { + align-self: flex-start; + font-size: 1.3em; + font-weight: bold; + } + + .folder-to-delete-parent-project { + color: $color-danger; + font-weight: bold; + } + + .delete-package-window-controls { + .package-to-delete { + color: $color-danger; + + font-weight: bold; + } + } + + + .delete-folder-window-controls { + .folder-to-delete { + } + + .folder-to-delete-parent-project { + } + } + + .delete-file-window-container { + display: flex; + flex-direction: column; + + .delete-file-window-title { + align-self: flex-start; + } + + + } + + .delete-file-window-controls { + .file-to-delete { + color: $color-danger; + font-weight: bold; + } + + + .file-to-delete-parent-project { + font-weight: bold; + // color: rgb(25, 168, 148); + // color: $color-danger; + + } + } + + .default-window { + .default-window-container { + h4 { + padding-bottom: 80px; + } + + .default-window-option { + line-height: 2.5em; + + span, + button > span { + margin-left: 20px; + } + + span { + // color: white; + } + + .add-project-button-text, + .install-package-button-text { + // color: white; + } + } + } + } + + + + .default-window-container, + .name-project-window-container, + .name-project-folder-window-container, + .name-file-window-container, + .delete-file-window-container, + .delete-folder-window-container, + .delete-package-window-container { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + width: 100%; + } + + .default-window, + .name-project-window, + .name-project-folder-window, + .name-file-window, + .delete-package-window, + .delete-file-window, + .delete-folder-window { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + width: 100%; + } + + .name-project-window-title, + .name-file-window-title { + text-align: center; + } + + .card-body { + } + + .name-file-window { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + + .name-file-window-container { + display: flex; + flex-direction: column; + height: 100%; + + width: 100%; + } + } + + .package-install-window { + + .install-package-button { + width: 105px; + display: inline-flex; + align-items: center; + justify-content: center; + + .install-package-status-icon { + } + } + &.github { + line-height:0.5em; + } + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + padding-left: 25%; + padding-right: 25%; + + width: 100%; + + .package-install-window-title { + font-size: 1.5em; + font-weight: bold; + text-align: center; + } + + + .card { + border: 0 solid transparent !important; + } + + .package-install-source-form { + align-items: center; + + display: flex; + flex-direction: row; + height: 100px; + + justify-content: center; + padding-left: 25%; + padding-right: 25%; + + width: 100%; + + + .package-install-option-container { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + width: 100px; + + + .package-install-source-icon { + font-size: 2em; + margin-bottom: 5px; + } + + .package-install-source-option { + height: 25px; + width: 25px; + } + } + } + + + .package-install-details-form { + .project-name-invalid-text { + + color: $color-danger !important; + margin-bottom: 20px; + display: block; + } + + + + .package-install-input-container { + display: flex; + flex-direction: column; + + margin-bottom: 20px; + + + .project-name-input.invalid:focus, + .project-name-input.invalid { + border: 1px solid $color-danger !important; + outline-color: $color-danger !important; + } + + + .search-status-icon-container { + text-align: center; + width: 40px; + + i { + color: white; + + + &.not-searching { + color: white; //rgba(0,255,0, 0.1); + } + + + &.found, &.loading { + color: green; + } + + &.not-found { + color: rgb(145, 50, 120); + } + + + } + } + + .package-install-deploy-targets-dropdown { + } + + + .package-install-github-query-container { + .package-install-github-user { + margin-bottom: 20px; + } + + .package-install-github-repo-lookup { + margin-bottom: 20px; + + .github-package-search-box { + align-items: center; + display: flex; + flex-direction: row; + justify-content: center; + + } + } + } + + .package-install-npm-lookup { + .package-install-npm-query-container { + align-items: center; + display: flex; + flex-direction: row; + margin-bottom: 20px; + } + } + + } + } + + } + + .editor { + &.current-file-path::before { + } + + + &.current-file-path { + align-self: flex-start; + color: darkgray; + + .current-file-path-icon { + padding: 5px; + + } + } + + + width: 100%; + } + + .package-install-source-form { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + width: 100%; + + .package-install-source-icon { + font-size: 2em; + + &.fab.fa-npm { + color: rgb(145, 50, 120); + font-size: 3em; + } + + &.fa-github, + &.fa-link { + padding: 5px; + } + + &.fa-link { + color: lightgray; + } + + &.fa-github { + color: rgb(28, 182, 149); + } + } + + .package-type { + border: 0 solid transparent !important; + display: flex; + flex-direction: row; + margin-bottom: 30px; + + label { + display: flex; + flex-direction: column; + justify-content: space-between; + min-width: 80px; + text-align: center; + } + + input:focus { + outline: none !important; + } + + input { + margin-top: 10px; + } + + } + } + + .filename-dialog { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + width: 100%; + } + } + + } +} + +@media(max-width: 814px) { + .web-ide { + overflow: initial; + + .file-browser-outer-container { + height: auto; + overflow-y: auto; + } + + .file-browser-scroll-container { + max-height: 500px; + } + } +} diff --git a/src/assets/styles/overrides/_akamai.scss b/src/assets/styles/overrides/_akamai.scss index 2d4f89941..5ae6703de 100644 --- a/src/assets/styles/overrides/_akamai.scss +++ b/src/assets/styles/overrides/_akamai.scss @@ -171,8 +171,8 @@ $partner-brand-color: #017ac6; .fake-input, .form-control { background: $lightest-grey-overlay !important; - color: $color-darkgrey !important; border: 1px solid $color-lightgrey !important; + color: $color-darkgrey !important; &.is-invalid, &.error { diff --git a/src/assets/styles/pages/_functions.scss b/src/assets/styles/pages/_functions.scss index 1333018f3..c01ebf2f6 100644 --- a/src/assets/styles/pages/_functions.scss +++ b/src/assets/styles/pages/_functions.scss @@ -1,16 +1,2 @@ #functions { - .entity-manager { - .card { - .card-body { - &.scrollable { - max-height: calc(27.8vh - 160px); - min-height: 110px; - - &.creating { - max-height: calc(27.8vh - 209px); - } - } - } - } - } } diff --git a/src/components/auth/SignUp.js b/src/components/auth/SignUp.js index 4ddce116b..969eed737 100644 --- a/src/components/auth/SignUp.js +++ b/src/components/auth/SignUp.js @@ -44,7 +44,7 @@ function SignUp() {
diff --git a/src/components/instance/browse/EntityManager.js b/src/components/instance/browse/EntityManager.js index 34d2e9ffd..c1ce7a8e0 100644 --- a/src/components/instance/browse/EntityManager.js +++ b/src/components/instance/browse/EntityManager.js @@ -1,13 +1,18 @@ import React, { useState, useEffect } from 'react'; import { Card, CardBody } from 'reactstrap'; +import { useStoreState } from 'pullstate'; import EntityManagerForm from './EntityManagerForm'; import EntityManagerRow from './EntityManagerRow'; import EntityManagerHeader from './EntityManagerHeader'; +import instanceState from '../../../functions/state/instanceState'; function EntityManager({ items, activeItem, activeSchema = false, showForm, baseUrl, itemType }) { const [isDropping, toggleDropItem] = useState(false); const [isCreating, toggleCreate] = useState(false); + const registration = useStoreState(instanceState, (s) => s.registration); + const [ major, minor ] = registration?.version.split('.') || []; + const versionAsFloat = parseFloat(`${major}.${minor}`); useEffect(() => { toggleCreate(); @@ -58,7 +63,7 @@ function EntityManager({ items, activeItem, activeSchema = false, showForm, base ) : items && !items.length && !showForm ? ( -
no visible schemas or tables
+
no visible { versionAsFloat >= 4.2 ? 'databases' : 'schemas' } or tables
) : null} diff --git a/src/components/instance/browse/index.js b/src/components/instance/browse/index.js index 4aea83fa8..0f3368e02 100644 --- a/src/components/instance/browse/index.js +++ b/src/components/instance/browse/index.js @@ -38,6 +38,10 @@ function BrowseIndex() { const [instanceAuths] = useInstanceAuth({}); const auth = instanceAuths && instanceAuths[compute_stack_id]; const url = useStoreState(instanceState, (s) => s.url); + const registration = useStoreState(instanceState, (s) => s.registration); + const version = registration?.version; + const [major, minor] = version?.split('.') || []; + const versionAsFloat = `${major}.${minor}`; const structure = useStoreState(instanceState, (s) => s.structure); const [entities, setEntities] = useState({ schemas: [], tables: [], activeTable: false }); const [tableState, setTableState] = useState(defaultTableState); @@ -45,7 +49,7 @@ function BrowseIndex() { const showForm = instanceAuths[compute_stack_id]?.super || instanceAuths[compute_stack_id]?.structure === true; const showTableForm = showForm || (instanceAuths[compute_stack_id]?.structure && instanceAuths[compute_stack_id]?.structure?.includes(schema)); const emptyPromptMessage = showForm - ? `Please ${(schema && entities.tables && !entities.tables.length) || !entities.schemas.length ? 'create' : 'choose'} a ${schema ? 'table' : 'schema'}` + ? `Please ${(schema && entities.tables && !entities.tables.length) || !entities.schemas.length ? 'create' : 'choose'} a ${schema ? 'table' : `${versionAsFloat >= 4.2 ? 'database' : 'schema'}` }` : "This user has not been granted access to any tables. A super-user must update this user's role."; const syncInstanceStructure = () => { @@ -100,17 +104,34 @@ function BrowseIndex() { return ( - addError({ error: { message: error.message, componentStack } })} FallbackComponent={ErrorFallback}> - - {schema && } - + addError({ error: { message: error.message, componentStack } })} + FallbackComponent={ErrorFallback}> + = 4.2 ? 'database' : 'schema'} + showForm={showForm} /> + { + schema && + } + = 4.2 ? 'databases' : 'schemas'} and tables`} />
- addError({ error: { message: error.message, componentStack } })} FallbackComponent={ErrorFallback}> + addError({ error: { message: error.message, componentStack } })} + FallbackComponent={ErrorFallback}> {schema && table && action === 'csv' && entities.activeTable ? ( ) : schema && table && action && entities.activeTable ? ( diff --git a/src/components/instance/functions/manage/CustomFunctionsEditor.js b/src/components/instance/functions/manage/CustomFunctionsEditor.js new file mode 100644 index 000000000..cb530b03f --- /dev/null +++ b/src/components/instance/functions/manage/CustomFunctionsEditor.js @@ -0,0 +1,128 @@ +import React, { useEffect } from 'react'; +import { Row, Col } from 'reactstrap'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useStoreState } from 'pullstate'; +import { useParams, useNavigate } from 'react-router-dom'; +import useInterval from 'use-interval'; + +import instanceState from '../../../../functions/state/instanceState'; + +import EntityManager from './EntityManager'; +import CodeEditor from './CodeEditor'; +import Deploy from './Deploy'; +import EmptyPrompt from '../../../shared/EmptyPrompt'; +import ErrorFallback from '../../../shared/ErrorFallback'; +import addError from '../../../../functions/api/lms/addError'; +import EntityReloader from './EntityReloader'; +import StaticEntityStatus from './StaticEntityStatus'; +import CopyableText from '../../../shared/CopyableText'; + +function CustomFunctionsEditor({ refreshCustomFunctions, loading }) { + const { customer_id, compute_stack_id, action = 'edit', project, file } = useParams(); + const navigate = useNavigate(); + const custom_functions = useStoreState(instanceState, (s) => s.custom_functions); + const registration = useStoreState(instanceState, (s) => s.registration); + const [majorVersion, minorVersion] = (registration?.version || '').split('.'); + const supportsStaticRoutes = parseFloat(`${majorVersion}.${minorVersion}`) < 4.1; + const restarting = useStoreState(instanceState, (s) => s.restarting_service === 'custom_functions'); + const cf_server_url = useStoreState(instanceState, (s) => s.custom_functions_url || `${s.url.split(':').slice(0, -1).join(':')}:${s.custom_functions?.port}`); + const baseUrl = `/o/${customer_id}/i/${compute_stack_id}/functions/${action}`; + + useEffect(() => { + const hasProjects = custom_functions?.endpoints && Object.keys(custom_functions?.endpoints).length; + const projectIsInEndpoints = custom_functions?.endpoints && Object.keys(custom_functions?.endpoints).includes(project); + + if (hasProjects && project && !projectIsInEndpoints) { + const firstProject = project && Object.keys(custom_functions?.endpoints)[0]; + navigate(`${baseUrl}/${firstProject}`); + } else if (hasProjects && project && !file) { + const firstRouteFile = project && custom_functions?.endpoints[project]?.routes[0]; + const firstHelperFile = project && custom_functions?.endpoints[project]?.helpers[0]; + const defaultType = firstRouteFile ? 'routes' : 'helpers'; + navigate(`${baseUrl}/${project}/${defaultType}/${firstRouteFile || firstHelperFile}`); + } else if (hasProjects && !project) { + const firstProject = Object.keys(custom_functions?.endpoints)[0]; + navigate(`${baseUrl}/${firstProject}`); + } else if (!hasProjects) { + navigate(baseUrl); + } + }, [custom_functions?.endpoints, customer_id, compute_stack_id, navigate, action, project, file, baseUrl]); + + useInterval(async () => { + if (cf_server_url && restarting) { + try { + await fetch(cf_server_url); + instanceState.update((s) => { + s.restarting_service = false; + }); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } + } + }, 1000); + + return ( + + + addError({ error: { message: error.message, componentStack } })} FallbackComponent={ErrorFallback}> + + {project && action === 'edit' && ( + <> + + + { supportsStaticRoutes && } + + + )} +
+
+
Root File Directory
+ +
Custom Functions Server URL
+ +
+
+ +
+ + + {action === 'deploy' ? ( + + ) : project ? ( + + ) : ( + + )} + +
+ ); +} + +export default CustomFunctionsEditor; diff --git a/src/components/instance/functions/manage/index.js b/src/components/instance/functions/manage/index.js index 0ab0c70c7..5c5ca5889 100644 --- a/src/components/instance/functions/manage/index.js +++ b/src/components/instance/functions/manage/index.js @@ -1,144 +1,447 @@ -import React, { useEffect } from 'react'; -import { Row, Col } from 'reactstrap'; -import { ErrorBoundary } from 'react-error-boundary'; +import React, { useState } from 'react'; import { useStoreState } from 'pullstate'; -import { useParams, useNavigate } from 'react-router-dom'; -import useInterval from 'use-interval'; +import { useParams } from 'react-router'; +import { useAlert } from 'react-alert'; +import appState from '../../../../functions/state/appState'; import instanceState from '../../../../functions/state/instanceState'; +import getComponentFile from '../../../../functions/api/instance/getComponentFile'; +import setComponentFile from '../../../../functions/api/instance/setComponentFile'; +import addComponent from '../../../../functions/api/instance/addComponent'; +import packageComponent from '../../../../functions/api/instance/packageComponent'; +import dropComponent from '../../../../functions/api/instance/dropComponent'; +import deployComponent from '../../../../functions/api/instance/deployComponent'; +import restartInstance from '../../../../functions/api/instance/restartInstance'; -import EntityManager from './EntityManager'; -import CodeEditor from './CodeEditor'; -import Deploy from './Deploy'; -import EmptyPrompt from '../../../shared/EmptyPrompt'; -import ErrorFallback from '../../../shared/ErrorFallback'; -import addError from '../../../../functions/api/lms/addError'; -import EntityReloader from './EntityReloader'; -import StaticEntityStatus from './StaticEntityStatus'; -import CopyableText from '../../../shared/CopyableText'; +import useInstanceAuth from '../../../../functions/state/instanceAuths'; + +import ApplicationsEditor from '../../../shared/webide'; +import CustomFunctionsEditor from './CustomFunctionsEditor'; + +/* + * Parse the relative path, which is what the components api expects. + * + * note: While 'components' root is not shown in the app, + * the full path is components//relative/path/to/file.js + */ + +function getRelativeFilepath(absolutePath) { + // for saving to server, which requires skipping root /components dir + // and treating /components/projects dir as project name. + // filepath is /path/to/file.js + + return absolutePath.split('/').slice(2).join('/'); + +} + +function getDeployTargets(instanceList, instanceAuthList, thisCsId, auth) { + + return instanceList.reduce((memo, instance) => { + + const csId = instance.compute_stack_id; + const deployTarget = instanceAuthList[csId]; + + if (!deployTarget?.version) { + return memo; + } + + const [ major, minor ] = deployTarget?.version.split('.') || []; + + // exclude < 4.2 + + if (parseInt(major, 10) >= 4 && parseInt(minor, 10) >= 2) { + + memo.push({ + isCurrentInstance: csId === thisCsId, + auth, + instance + }); + + } + + return memo; + + }, []); + +} function ManageIndex({ refreshCustomFunctions, loading }) { - const { customer_id, compute_stack_id, action = 'edit', project, file } = useParams(); - const navigate = useNavigate(); - const custom_functions = useStoreState(instanceState, (s) => s.custom_functions); + + const { compute_stack_id } = useParams(); const registration = useStoreState(instanceState, (s) => s.registration); - const [majorVersion, minorVersion] = (registration?.version || '').split('.'); - const supportsStaticRoutes = parseFloat(`${majorVersion}.${minorVersion}`) < 4.1; - const restarting = useStoreState(instanceState, (s) => s.restarting_service === 'custom_functions'); - // NOTE: use the operationsApi server for cf_server_url in the future. - const cf_server_url = useStoreState(instanceState, (s) => s.custom_functions_url || `${s.url.split(':').slice(0, -1).join(':')}:${s.custom_functions?.port}`); - const baseUrl = `/o/${customer_id}/i/${compute_stack_id}/functions/${action}`; + const { fileTree } = useStoreState(instanceState, (s) => s.custom_functions); + const auth = useStoreState(instanceState, (s) => s.auth); + const url = useStoreState(instanceState, (s) => s.url); + const [majorVersion, minorVersion] = (registration?.version || '').split('.') || []; + const supportsApplicationsAPI = parseFloat(`${majorVersion}.${minorVersion}`) >= 4.2; + const instances = useStoreState(appState, (s) => s.instances); + const [instanceAuths] = useInstanceAuth({}); + const theme = useStoreState(appState, (s) => s.theme); + const [ restartingInstance, setRestartingInstance ] = useState(false); + const alert = useAlert(); + + async function restartWithLoadingState({ auth: instanceAuth, url: instanceUrl }) { + + setRestartingInstance(true); + + setTimeout(async () => { + await restartInstance({ auth: instanceAuth, url: instanceUrl }); + setRestartingInstance(false); + }, 100); + + } + + // save file to instance + async function saveCodeToInstance(selectedFile, restartRequired) { + + const filepathRelativeToProjectDir = selectedFile.path.split('/').slice(2).join('/'); + const payload = { + auth, + url, + project: selectedFile.project, + file: filepathRelativeToProjectDir, + payload: selectedFile.content + }; + + const { error, message } = await setComponentFile(payload); + + if (error) { + alert.error(message); + } + + + if (restartRequired) { + await restartWithLoadingState({ auth, url }); + } + + await refreshCustomFunctions(); + + } + + // eslint-disable-next-line no-empty-function + async function renameFolder() { + } + + async function renameFile(newFileName, info) { + + const { path, content, project } = info; + const parentDir = getRelativeFilepath(path).split('/').slice(0, -1).join('/'); + const newFilenameRelativePath = parentDir ? `${parentDir}/${newFileName}` : newFileName; + + // TODO: error handling + await dropComponent({ + auth, + url, + project, + file: getRelativeFilepath(path) + }); + + await setComponentFile({ + auth, + url, + project, + file: newFilenameRelativePath, + payload: content + }) + + refreshCustomFunctions(); + + } + + async function selectNewFile(selectedFile) { + + const { path, project, name } = selectedFile; + const newFile = getRelativeFilepath(path); + const { error, message } = await getComponentFile({ + auth, + url, + project, + file: newFile + }); + + if (error) { + + alert.error(message); + + return { + content: '', + path, + project, + name + }; + + } + + return { + content: message, + path, + project, + name + }; + + } + + async function deleteFile(f) { + + const { error, message } = await dropComponent({ + auth, + url, + project: f.project, + file: getRelativeFilepath(f.path) + }); + + if (error) { + alert.error(message); + } + + await refreshCustomFunctions(); + + } - const routeToDefaultProject = () => { + async function deletePackage({ name: project }) { - const hasProjects = custom_functions?.endpoints && Object.keys(custom_functions?.endpoints).length; - const projectIsInEndpoints = custom_functions?.endpoints && Object.keys(custom_functions?.endpoints).includes(project); + const { error, message } = await dropComponent({ + auth, + url, + project + }); - let targetUrl; + if (error) { + alert.error(message); + return; + } + + await restartInstance({ auth, url }); + await refreshCustomFunctions(); + + } + + async function deleteFolder({ path, project }) { + + + const targetDirpath = getRelativeFilepath(path); + + // if we're deleting a top-level directory, that's a project, + // so don't pass a file. otherwise pass project name and file/dir + // relative to project name as 'file'. + + if (targetDirpath.length > 0) { + + const { error, message } = await dropComponent({ + auth, + url, + project, + file: targetDirpath + }); + + if (error) { + alert.error(message); + } + + } else { + + const { error, message } = await dropComponent({ + auth, + url, + project + }); + + if (error) { + alert.error(message); + } - if (hasProjects && project && !projectIsInEndpoints) { + } + + await refreshCustomFunctions(); + + } + + async function createNewProject(newProjectName) { + + const { error, message } = await addComponent({ + auth, + url, + project: newProjectName + }); + + + if (error) { + alert.error(message); + } - const firstProject = project && Object.keys(custom_functions?.endpoints)[0]; - targetUrl = `${baseUrl}/${firstProject}`; + await restartInstance({ auth, url }); - } else if (hasProjects && project && !file) { + await refreshCustomFunctions(); - const firstRouteFile = project && custom_functions?.endpoints[project]?.routes[0]; - const firstHelperFile = project && custom_functions?.endpoints[project]?.helpers[0]; - const defaultType = firstRouteFile ? 'routes' : 'helpers'; - targetUrl = `${baseUrl}/${project}/${defaultType}/${firstRouteFile || firstHelperFile}`; + } - } else if (hasProjects && !project) { + async function createNewProjectFolder(newFolderName, parentFolder) { - const firstProject = Object.keys(custom_functions?.endpoints)[0]; - targetUrl = `${baseUrl}/${firstProject}`; + const { path, project } = parentFolder; + const relativeDirpath = getRelativeFilepath(path); + const relativeFilepath = relativeDirpath ? `${relativeDirpath}/${newFolderName}` : newFolderName; - } else if (!hasProjects) { + const { error, message } = await setComponentFile({ + auth, + url, + project, + file: relativeFilepath + }) - targetUrl = baseUrl; + if (error) { + alert.error(message); } - navigate(targetUrl); + await refreshCustomFunctions(); + } - const waitForRestartToComplete = async () => { - if (cf_server_url && restarting) { - try { - await fetch(cf_server_url); - instanceState.update((s) => { - s.restarting_service = false; - }); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); + async function installPackage(projectName, packageUrl, deployTargets) { + + const deployPromises = deployTargets.map(async (t) => { + + // TODO: check error here. + + const { error, message } = await deployComponent({ + + auth: t.auth, + url: t.instance.url, + project: projectName, + packageUrl + }); + + if (error) { + alert.error(message); } + + // TODO: what do we actually want to do about an invalid package? + // change to restartService({ auth, url, service: 'http_worker' }); + await restartInstance({ auth: t.auth, url: t.instance.url }); + + }); + + + await Promise.all(deployPromises); + await refreshCustomFunctions(); + + /* + for (const deployTarget of deployTargets) { + + // TODO: check error here. + await deployComponent({ + auth: deployTarget.auth, + url: deployTarget.instance.url, + project: projectName, + packageUrl + }); + + // TODO: what do we actually want to do about an invalid package? + // change to restartService({ auth, url, service: 'http_worker' }); + + await restartInstance({ auth: deployTarget.auth, url: deployTarget.instance.url }); + } + */ + + await refreshCustomFunctions(); + + } - useEffect(routeToDefaultProject, [custom_functions?.endpoints, customer_id, compute_stack_id, navigate, action, project, file, baseUrl]); - useInterval(waitForRestartToComplete, 1000); - - return ( - - - addError({ error: { message: error.message, componentStack } })} FallbackComponent={ErrorFallback}> - - {project && action === 'edit' && ( - <> - - - { supportsStaticRoutes && } - - - )} -
-
-
Root File Directory
- -
Custom Functions Server URL
- -
-
- -
- - - {action === 'deploy' ? ( - - ) : project ? ( - - ) : ( - - )} - -
- ); + async function createNewFile(newFilename, parentFolder) { + + const { path, project } = parentFolder; + const relativeDirpath = getRelativeFilepath(path); + const relativeFilepath = relativeDirpath ? `${relativeDirpath}/${newFilename}` : newFilename; + const payload = ''; + + // NOTE: to server, path is everything after '/components/project' in /components/project/path/to/file.js + // in IDE land, path is full, aka, 'components/project/path/to/file.js'. + + await setComponentFile({ + auth, + url, + project, + file: relativeFilepath, + payload + }); + + await refreshCustomFunctions(); + + return { + content: payload, + path: [parentFolder.path, newFilename].join('/'), + project, + }; + + } + + async function deployProject({ project, deployTarget }) { + + const { + auth: otherInstanceAuth, + instance: otherInstance + } = deployTarget; + + const { payload } = await packageComponent({ + auth, + url, + project: project.name + }); + + // deploy to targetInstance + await deployComponent({ + auth: otherInstanceAuth, + url: otherInstance.url, + project: project.name, + payload + }) + + // restart targetInstance + await restartInstance({ + auth: otherInstanceAuth, + url: otherInstance.url + }); + + // restart this instance + await restartInstance({ + auth, + url + }); + + await refreshCustomFunctions(); + + } + + return supportsApplicationsAPI ? + restartWithLoadingState({auth, url}) } + restartingInstance={restartingInstance} + /> + : + + } export default ManageIndex; diff --git a/src/components/instance/index.js b/src/components/instance/index.js index fdd78b361..76c0f16df 100644 --- a/src/components/instance/index.js +++ b/src/components/instance/index.js @@ -36,7 +36,8 @@ function InstanceIndex() { const thisInstance = useStoreState(appState, (s) => compute_stack_id && s.instances && s.instances.find((i) => i.compute_stack_id === compute_stack_id), [compute_stack_id]); const url = useStoreState(instanceState, (s) => s.url); const restarting = useStoreState(instanceState, (s) => s.restarting); - const hydratedRoutes = routes({ customer_id, super_user: instanceAuth?.super }); + const registration = useStoreState(instanceState, (s) => s.registration); + const hydratedRoutes = routes({ customer_id, super_user: instanceAuth?.super, version: registration?.version }); const [mounted, setMounted] = useState(false); useAsyncEffect(() => { diff --git a/src/components/instance/roles/JsonViewer.js b/src/components/instance/roles/JsonViewer.js index 73b1727a0..9bf339574 100644 --- a/src/components/instance/roles/JsonViewer.js +++ b/src/components/instance/roles/JsonViewer.js @@ -22,6 +22,8 @@ function JsonViewer({ showAttributes, fetchRoles }) { const { role_id } = useParams(); const roles = useStoreState(instanceState, (s) => s.roles); const version = useStoreState(instanceState, (s) => s.registration?.version); + const [ major, minor ] = version?.split('.') || []; + const versionAsFloat = parseFloat(`${major}.${minor}`); const lastUpdate = useStoreState(instanceState, (s) => s.lastUpdate); const auth = useStoreState(instanceState, (s) => s.auth); const url = useStoreState(instanceState, (s) => s.url); @@ -33,13 +35,12 @@ function JsonViewer({ showAttributes, fetchRoles }) { const [loading, setLoading] = useState(false); const [changed, setChanged] = useState(false); const [validJSON, setValidJSON] = useState(true); - const [major, minor] = version.split('.'); const hasStructureUser = major >= 3 && (major > 3 || minor >= 3); const jsonHeight = hasStructureUser ? "calc(100vh - 440px)" : "calc(100vh - 340px)" const icons = { - checked:
manage schemas/tables
, - unchecked:
manage schemas/tables
, + checked:
manage { versionAsFloat >= 4.2 ? 'databases' : 'schemas' }/tables
, + unchecked:
manage { versionAsFloat >= 4.2 ? 'databases' : 'schemas' }/tables
, }; useAsyncEffect(async () => { @@ -113,8 +114,8 @@ function JsonViewer({ showAttributes, fetchRoles }) { }} type="text" id="permitted_schemas" - title="permitteed schemas" - placeholder="comma-separated schemas, or blank for all" + title={`permitted ${ versionAsFloat >= 4.2 ? 'databases' : 'schemas' }`} + placeholder={`comma-separated ${ versionAsFloat >= 4.2 ? 'databases' : 'schemas' }, or blank for all`} value={newStructureUser === true ? '' : newStructureUser} disabled={loading} /> diff --git a/src/components/instance/roles/index.js b/src/components/instance/roles/index.js index d9280aa27..eae8e9cef 100644 --- a/src/components/instance/roles/index.js +++ b/src/components/instance/roles/index.js @@ -38,6 +38,9 @@ function RolesIndex() { const [loading, setLoading] = useState(false); const [formState, setFormState] = useState(defaultState); const baseUrl = `/o/${customer_id}/i/${compute_stack_id}/roles`; + const version = useStoreState(instanceState, (s) => s.registration?.version); + const [ major, minor ] = version?.split('.') || []; + const versionAsFloat = parseFloat(`${major}.${minor}`); const fetchRoles = useCallback(async () => { setLoading(true); @@ -109,7 +112,7 @@ function RolesIndex() { {role_id ? ( -
Super Users and Cluster Users have full access to all schemas, tables, and attributes.
+
Super Users and Cluster Users have full access to all { versionAsFloat >= 4.2 ? 'databases' : 'schemas'}, tables, and attributes.
) : (
Please choose or add a role to manage it.
)} diff --git a/src/components/instance/routes.js b/src/components/instance/routes.js index d58bd0c15..f5a76e824 100644 --- a/src/components/instance/routes.js +++ b/src/components/instance/routes.js @@ -75,6 +75,15 @@ const functions = { iconCode: 'f542', }; +const applications = { + element: , + path: 'applications', + link: 'applications', + label: 'applications', + icon: 'project-diagram', + iconCode: 'f542', +}; + const metrics = { element: , path: `status`, @@ -102,6 +111,26 @@ const examples = { iconCode: 'f121', }; -const routes = ({ super_user }) => (super_user ? [browse, query, users, roles, charts, cluster, functions, metrics, config, examples] : [browse, query, charts, examples]); +const routes = ({ super_user, version=null }) => { + + let supportsApplications = false; + + if (version) { + + const [ a, b ] = version?.split('.') || []; + + const major = parseInt(a, 10); + const minor = parseInt(b, 10); + const versionAsFloat = parseFloat(`${major}.${minor}`); + + supportsApplications = versionAsFloat >= 4.2; + + } + if (super_user) { + return [browse, query, users, roles, charts, cluster, supportsApplications ? applications : functions, metrics, config, examples]; + } + return [browse, query, charts, examples]; + +} export default routes; diff --git a/src/components/shared/webide/Editor.js b/src/components/shared/webide/Editor.js new file mode 100644 index 000000000..67a08411c --- /dev/null +++ b/src/components/shared/webide/Editor.js @@ -0,0 +1,66 @@ +import React, { useState, useEffect } from 'react'; +import ReactMonacoEditor from '@monaco-editor/react'; + +function parseFileExtension(filename) { + const parts = (filename || '')?.split('.'); + return parts.length > 1 ? parts.slice(-1)[0] : ''; +} + +const extensionToLanguageMap = { + js: 'javascript', + yaml: 'yaml', + ts: 'typescript', + json: 'json', + md: 'markdown', + html: 'html', + css: 'css', + graphql: 'graphql' +}; + +// TODO: update code using whatever monaco hook is available. onupdate. +// don't allow save if there are errors. +function Editor({ active, file, onChange, theme }) { + + const [ language, setLanguage ] = useState('javascript'); + const [ editorTheme, setEditorTheme ] = useState('vs-dark'); + + useEffect(() => { + const extension = parseFileExtension(file?.name); + const updatedLanguage = extensionToLanguageMap[extension] || 'plaintext'; + setLanguage(updatedLanguage); + }, [file]); + + useEffect(() => { + setEditorTheme(theme === 'light' ? 'light' : 'vs-dark'); + }, [theme]); + + + if (!active) { + return null; + } + + const filepathRelativeToComponentsDir = file.path.split('/').slice(1).join('/'); + + // eslint-disable-next-line no-unused-vars + return <> +
+ + {filepathRelativeToComponentsDir} +
+ + +} + +export default Editor; diff --git a/src/components/shared/webide/EditorMenu.js b/src/components/shared/webide/EditorMenu.js new file mode 100644 index 000000000..3f09991d7 --- /dev/null +++ b/src/components/shared/webide/EditorMenu.js @@ -0,0 +1,91 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React, { useState } from 'react'; +import cn from 'classnames'; + +export function SaveButton({ disabled, onSave }) { + + const [ loading, setLoading ] = useState(false); + return ( + + ); +} + +export function RestartOnSaveToggle({ restartAfterSave, onClick }) { + + const title = restartAfterSave ? + 'your instance will restart after saving application files' : + 'your instance will not restart after saving application files'; + + return ( + + ); +} + +export default function EditorMenu({ SaveButton: SaveBtn, RestartInstanceButton: RestartInstanceBtn, RestartOnSaveToggle: RestartOnSaveTgl }) { + return ( +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ ) +} diff --git a/src/components/shared/webide/EditorWindow.js b/src/components/shared/webide/EditorWindow.js new file mode 100644 index 000000000..04c39ae74 --- /dev/null +++ b/src/components/shared/webide/EditorWindow.js @@ -0,0 +1,39 @@ +import React from 'react'; + +export { + DefaultWindow, + GithubRepoSelector, + NpmPackageSelector, + PackageInstallWindow, + UrlInstallField, + NameProjectWindow, + NameProjectFolderWindow, + NameFileWindow, + DeleteFileWindow, + DeleteFolderWindow, + DeletePackageWindow +} from './windows'; + +export const EDITOR_WINDOWS = { + DEFAULT_WINDOW: 'DEFAULT_WINDOW', + CODE_EDITOR_WINDOW: 'CODE_EDITOR_WINDOW', + NAME_FILE_WINDOW: 'NAME_FILE_WINDOW', + NAME_PROJECT_WINDOW: 'NAME_PROJECT_WINDOW', + NAME_PROJECT_FOLDER_WINDOW: 'NAME_PROJECT_FOLDER_WINDOW', + RENAME_FILE_WINDOW: 'RENAME_FILE_WINDOW', + RENAME_FOLDER_WINDOW: 'RENAME_FOLDER_WINDOW', + DEPLOY_COMPONENT_WINDOW: 'DEPLOY_COMPONENT_WINDOW', + INSTALL_PACKAGE_WINDOW: 'INSTALL_PACKAGE_WINDOW', + PACKAGE_DETAILS_WINDOW: 'PACKAGE_DETAILS_WINDOW', + DELETE_FILE_WINDOW: 'DELETE_FILE_WINDOW', + DELETE_FOLDER_WINDOW: 'DELETE_FOLDER_WINDOW', + DELETE_PACKAGE_WINDOW: 'DELETE_PACKAGE_WINDOW' +}; + +export default function EditorWindow({ children }) { + return ( +
+ { children } +
+ ); +} diff --git a/src/components/shared/webide/FileBrowser.js b/src/components/shared/webide/FileBrowser.js new file mode 100644 index 000000000..fb486da5a --- /dev/null +++ b/src/components/shared/webide/FileBrowser.js @@ -0,0 +1,269 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React, { useState, useEffect } from 'react'; +import cn from 'classnames'; + +function NoProjects() { + return ( +
+

You have no HarperDB applications yet. Click the button in the menu above to create your first application!

+

See the documentation for more info on HarperDB Applications.

+
+ ); +} + +function parseFileExtension(name) { + return name && [...name].includes('.') ? + name.split('.').slice(-1)[0] + : null; +} +function directorySortComparator(a, b) { + + // TODO: refactor. + + // directories first, then flat files sorted + // ascending, alphanumerically + const A = +Boolean(a.entries); + const B = +Boolean(b.entries); + + return A === B ? a.name.localeCompare(b.name) : B - A; + +} + +const isFolder = (entry) => Boolean(entry.entries); + + +function ProjectIcon() { + return ; + +} +function FolderIcon({ toggleClosed }) { + return ; +} + +function FiletypeIcon({ extension }) { + switch (extension) { + case 'js': + return ; + case 'yaml': + return ; + default: + return ; + } +} + +function PackageIcon() { + return ; +} + +function Package({ name, url, onPackageSelect, selectedPackage }) { + + // FIXME: when we click another package, they both get selected. + const [ selected, setSelected ] = useState(Boolean(selectedPackage) && name === selectedPackage?.name); + + useEffect(() => { + setSelected(selectedPackage && selectedPackage.name === name); + }, [selectedPackage, name]); + + return ( + + ); + +} + +function File({ directoryEntry, selectedFile, selectedFolder, onFileSelect, onFolderSelect, Icon }) { + + const isDir = isFolder(directoryEntry); + const renameFileIconClass = 'rename-file'; + const deployFileIconClass = 'deploy-project'; + const isFileSelected = directoryEntry.path === selectedFile; + const isFolderSelected = directoryEntry.path === selectedFolder?.path; + + // file receives open/close toggle func from + // parent. if it's a dir, calls toggle func on click + // if it's a flat file, calls onFileSelect so + // parent can get file content. + + + function noOp() { + // TODO: figure out how to handle keyboard events properly. + // for now, use this to avoid react a11y errors. + } + + function handleToggleSelected(e) { + + + // TODO FIX HANDLING SO WE CAN HAVE NUANCED CLICK BEHAVIOR + + + // set the folder/file as currently selected folder/file + // visually highlight directory name + // note: if directory already highlighted, make sure if we've clicked on the pencil/edit icon + // that we don't untoggle directory selection; leave selected if icon clicked. + const iconWasClicked = e.target.classList.contains(renameFileIconClass) || e.target.classList.contains(deployFileIconClass); + + // if icon's clicked, select, but don't unselect. + // if (iconWasClicked) return; + + if (isDir) { + // one click on dir name toggles selected / highlighted state / ui + if (isFolderSelected && iconWasClicked) { + // TODO: don't + } else { + onFolderSelect(isFolderSelected ? null : directoryEntry); + } + } else if (isFileSelected) { + onFileSelect(null); + } else { + // one click on file name sets it to selected / highlighted + // AND retrieves file content + onFileSelect(directoryEntry); + } + + } + + return ( + + ) + +} + +function Folder({ directoryEntry, userOnSelect, onFolderSelect, onDeployProject, onFileSelect, onPackageSelect, onFileRename, selectedFile, selectedFolder, selectedPackage }) { + + const [ open, setOpen ] = useState(true); + + const entries = [...(directoryEntry.entries || [])].sort(directorySortComparator); + const fileExtension = parseFileExtension(directoryEntry.name); + + let Icon; + // top-level dir === package + // FolderIcon/PackageIcon is func so we can give it open args now, but instantiate it later. + if (directoryEntry.path.split('/').length === 2) { + if (directoryEntry.package) { + Icon = () => PackageIcon({ isOpen: open, toggleClosed: () => setOpen(!open) }); + } else { + Icon = () => ProjectIcon({ toggleClosed: () => setOpen(!open) }); + } + } else if (directoryEntry.entries) { + Icon = () => FolderIcon({ isOpen: open, toggleClosed: () => setOpen(!open) }); + } else { + Icon = () => FiletypeIcon({ extension: fileExtension }); + } + + return ( + <> + { + // FIXME: don't hardcode 'components', get from root .name property of fileTree. + directoryEntry.name !== 'components' ? +
  • + { + directoryEntry.package ? + : + { + onFileRename(directoryEntry) + } + } + onFileSelect={onFileSelect} + onFolderSelect={onFolderSelect} + userOnSelect={userOnSelect} /> + } +
  • + : null + } + + { + entries.map((entry) => ( +
  • +
      + +
    +
  • + )) + } + + + ) + +} + +// A recursive directory tree representation +function FileBrowser({ files, userOnSelect, onFileSelect, onPackageSelect, onDeployProject, onFileRename, onFolderSelect, selectedFile, selectedFolder, selectedPackage }) { + return !files?.entries?.length ? + : +
    +
      + +
    +
    +} + +export default FileBrowser diff --git a/src/components/shared/webide/FileMenu.js b/src/components/shared/webide/FileMenu.js new file mode 100644 index 000000000..17d75bae9 --- /dev/null +++ b/src/components/shared/webide/FileMenu.js @@ -0,0 +1,91 @@ +import React from 'react'; +import cn from 'classnames'; +import { v4 as uuid } from 'uuid'; + + +export function DeleteFolderButton({ onClick, disabled }) { + return ( + + ) +} + + +export function AddProjectButton({ disabled=false, onAddProject, text="" }) { + return ( + + ); +} + +export function AddFileButton({ disabled, onAddFile }) { + return ( + + +
    + + + ); + +} diff --git a/src/components/shared/webide/windows/DeleteFolderWindow.js b/src/components/shared/webide/windows/DeleteFolderWindow.js new file mode 100644 index 000000000..12112bb1c --- /dev/null +++ b/src/components/shared/webide/windows/DeleteFolderWindow.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { Card, CardTitle, CardBody } from 'reactstrap'; + +export default function DeleteFolderWindow({ active, selectedFolder, onConfirm, onCancel }) { + + if (!active) { + return null; + } + + const {project} = selectedFolder; + const pathSegmentsFromRoot = selectedFolder.path.split('/'); + const isProjectFolder = pathSegmentsFromRoot.length === 2; + const projectSubdir = isProjectFolder ? null : pathSegmentsFromRoot.slice(-1)[0]; + + return ( + + +
    + Delete Confirmation + { + isProjectFolder ? +

    Are you sure you want to delete project { project } ?

    : +

    Are you sure you want to delete the folder + {projectSubdir} from project { project } ? +

    + } + + +
    +
    +
    + ); + +} diff --git a/src/components/shared/webide/windows/DeletePackageWindow.js b/src/components/shared/webide/windows/DeletePackageWindow.js new file mode 100644 index 000000000..6f88ade88 --- /dev/null +++ b/src/components/shared/webide/windows/DeletePackageWindow.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { Card, CardTitle, CardBody } from 'reactstrap'; + +export default function DeletePackageWindow({ active, selectedPackage, onConfirm, onCancel }) { + + if (!active) { + return null; + } + + const packageName = selectedPackage.name; + + return ( + + +
    + Delete Confirmation +

    Are you sure you want to delete package { packageName } ?

    + + +
    +
    +
    + ); + +} diff --git a/src/components/shared/webide/windows/GithubRepoSelector.js b/src/components/shared/webide/windows/GithubRepoSelector.js new file mode 100644 index 000000000..121b45b99 --- /dev/null +++ b/src/components/shared/webide/windows/GithubRepoSelector.js @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; +import { useDebounce } from 'use-debounce'; +import cn from 'classnames'; +import Select from 'react-select'; +import { ensureRepoExists, getGithubTags } from './lib'; + +export default function GithubRepoSelector({ pkg, setPackageSpec }) { + + const [ user, setUser ] = useState(pkg?.user || ''); + const [ debouncedUser ] = useDebounce(user, 300); + + const [ repo, setRepo ] = useState(pkg?.repo || ''); + const [ debouncedRepo ] = useDebounce(repo, 300); + + const [ tags, setTags ] = useState([]); + // eslint-disable-next-line no-unused-vars + const [ tagsFetched, setTagsFetched ] = useState(false); + + const [ selectedTag, setSelectedTag ] = useState(pkg?.tag || ''); + const [ targetRepo, setTargetRepo ] = useState(''); + + const [ loadingTags, setLoadingTags ] = useState(false); + const [ found, setFound ] = useState(false); + + useEffect(() => { + + setUser(pkg?.user || ''); + setRepo(pkg?.repo || ''); + setTags(pkg?.tag ? [ pkg?.tag ] : []); + setSelectedTag(pkg?.tag || ''); + + }, [pkg]); + + // TODO: use named hook callbacks + useEffect(() => { + + if (debouncedUser && debouncedRepo) { + + setLoadingTags(true); + ensureRepoExists(user, debouncedRepo).then((targetRepoName) => { + + if (targetRepoName) { + + setTargetRepo(targetRepoName); + setFound(true); + + getGithubTags(user, debouncedRepo).then(repoTags => { + + setLoadingTags(false); + setTagsFetched(true); + setTags(repoTags); + + }) + + } else { + + setLoadingTags(false); + setFound(false); + setTargetRepo(''); + setTags([]); + + } + + }).catch(() => { + setLoadingTags(false); + }); + + } else { + + setFound(false); + setLoadingTags(false); + + setTags([]); + setSelectedTag(''); + } + + }, [debouncedUser, debouncedRepo, user]); + + // TODO: use named hook callbacks + useEffect(() => { + + if (!(user && repo && targetRepo)) { + setPackageSpec(''); + } else { + const packageSpec = selectedTag ? `${user}/${repo}#semver:${selectedTag}` : `${user}/${repo}`; + setPackageSpec(packageSpec); + } + + }, [targetRepo, selectedTag, user, setPackageSpec, repo]); + + return ( +
    +
    + + setUser(e.target.value) } + value={user} /> +
    +
    + +
    + setRepo(e.target.value) } + value={repo} /> + + 0 && debouncedRepo.length > 0 && found, + "fa-times not-found": debouncedRepo.length > 0 && debouncedUser.length > 0 && !(loadingTags || found), + "fa-check not-searching": debouncedUser.length === 0 || debouncedRepo.length === 0 + }) + } /> + +
    +
    + 0 && !isValidName }) } + autoFocus + onChange={(e) => { + setName(e.target.value); + setIsValidName(validate(e.target.value)); + }} + onKeyDown={ handleKeyDown } + value={name} + placeholder={placeholder} + title="choose name for your new file or folder" /> + +
    + { name.length > 0 && !isValidName && error: project name must contain only alphanumeric characters, dashes and underscores. } + + +
    + + + +
    + + ); + +} diff --git a/src/components/shared/webide/windows/NameProjectFolderWindow.js b/src/components/shared/webide/windows/NameProjectFolderWindow.js new file mode 100644 index 000000000..86ac934d3 --- /dev/null +++ b/src/components/shared/webide/windows/NameProjectFolderWindow.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { Card, CardTitle } from 'reactstrap'; +import NameInput from './NameInput'; + + +export default function NameProjectFolderWindow({ active, onConfirm, onCancel }) { + + return !active ? null : ( + + Name Your Folder + + + ); + +} diff --git a/src/components/shared/webide/windows/NameProjectWindow.js b/src/components/shared/webide/windows/NameProjectWindow.js new file mode 100644 index 000000000..47344c554 --- /dev/null +++ b/src/components/shared/webide/windows/NameProjectWindow.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { Card, CardTitle } from 'reactstrap'; +import NameInput from './NameInput'; +import { isValidProjectName } from './lib'; + +export default function NameProjectWindow({ active, onConfirm, onCancel }) { + return !active ? null : ( + + Name Your Project + + + ); +} diff --git a/src/components/shared/webide/windows/NpmPackageSelector.js b/src/components/shared/webide/windows/NpmPackageSelector.js new file mode 100644 index 000000000..d29b2f224 --- /dev/null +++ b/src/components/shared/webide/windows/NpmPackageSelector.js @@ -0,0 +1,142 @@ +import React, { useState, useEffect } from 'react'; +import { useDebounce } from 'use-debounce'; +import cn from 'classnames'; +import SelectDropdown from 'react-select'; + +import { + + findNpmPackageName, + getNpmDistTags + +} from './lib'; + +export default function NpmPackageSelector({ pkg, setPackageSpec }) { + + /* + * packageQuery is what's in the input field (@scope/package) + * matchingPackage is the name of the package that matches the search exactly + */ + + const [ packageQuery, setPackageQuery ] = useState(''); + const [ debouncedPackageQuery ] = useDebounce(packageQuery, 300); + const [ distTags, setDistTags ] = useState(''); + const [ selectedDistTag, setSelectedDistTag ] = useState(''); + const [ matchingPackage, setMatchingPackage ] = useState(''); + + // result is a packageSepc + // for package query status icons + const [ loadingTags, setLoadingTags ] = useState(false); + const [ found, setFound ] = useState(false); + + + function updatePackageQuery(e) { + setPackageQuery(e.target.value); + } + + function updateSelectedDistTag({value}) { + setSelectedDistTag(value); + } + + useEffect(() => { + + setPackageQuery(pkg?.package || ''); + }, [pkg]); + + + // searches npm using current, debounced query + // returns an exactly matching package name if it exists + // loads available tags if package name exists + + function updatePackageSpec() { + if (matchingPackage) { + const npmPackageSpecifier = selectedDistTag ? `${matchingPackage}@${selectedDistTag}` : matchingPackage; + setPackageSpec(npmPackageSpecifier); + } else { + setPackageSpec(''); + } + } + + useEffect(updatePackageSpec, [matchingPackage, selectedDistTag, setPackageSpec]); + + function updatePackageAndTags() { + + + if (debouncedPackageQuery) { + + setLoadingTags(true); + findNpmPackageName(debouncedPackageQuery).then(packageName => { + + setLoadingTags(false); + setFound(!!packageName); + setMatchingPackage(packageName); + + + if (packageName) { + + getNpmDistTags(packageName).then(tags => { + setDistTags(tags); + setSelectedDistTag(null); + }).catch(e => { + throw e; + }); + + } + + }).catch(() => { + setLoadingTags(false); + }); + + } else { + setMatchingPackage(null); + setDistTags(null); + setSelectedDistTag(null); + } + + } + + useEffect(updatePackageAndTags, [debouncedPackageQuery]); + + return ( + <> +
    + +
    + + + 0 && found, + "fa-times not-found": debouncedPackageQuery.length > 0 && !(loadingTags || found), + "fa-check not-searching": debouncedPackageQuery.length === 0 + }) + } /> + +
    +
    +
    + + ({ + label: `${tagName} (${tagValue})`, + value: tagName + })) + } /> + +
    + + ); + +} diff --git a/src/components/shared/webide/windows/PackageInstallWindow.js b/src/components/shared/webide/windows/PackageInstallWindow.js new file mode 100644 index 000000000..714b66685 --- /dev/null +++ b/src/components/shared/webide/windows/PackageInstallWindow.js @@ -0,0 +1,198 @@ +import React, { useState, useEffect } from 'react'; +import { Label, Button, Card } from 'reactstrap'; +import cn from 'classnames'; +import Select from 'react-select'; + +import { isValidProjectName, parsePackageType } from './lib'; +import GithubRepoSelector from './GithubRepoSelector'; +import NpmPackageSelector from './NpmPackageSelector'; +import UrlInstallField from './UrlInstallField'; + +export default function PackageInstallWindow({ selectedPackage, onConfirm, onCancel, deployTargets: availableDeployTargets }) { + + const packageTypes = [ 'npm', 'github', 'url' ]; + const defaultPackageType = packageTypes[0]; + + const [ installed, setInstalled ] = useState(Boolean(selectedPackage)); + + const [ packageInfo, setPackageInfo ] = useState(parsePackageType(selectedPackage)); + const [ packageType, setPackageType ] = useState(packageInfo?.type || defaultPackageType); + + const [ projectName, setProjectName ] = useState(selectedPackage?.name || ''); + const [ projectNameIsValid, setProjectNameIsValid ] = useState(true); + + const [ packageSpec, setPackageSpec ] = useState(''); + const [ loading, setLoading ] = useState(false); + + // result: 'packageSpec' gets sent to harperdb and installed by npm + + const [ deployTargets, setDeployTargets ] = useState([]); + + function updatePackageInfo() { + + const newPackageInfo = parsePackageType(selectedPackage); + + setProjectName(selectedPackage?.name || ''); + setPackageInfo(parsePackageType(selectedPackage)); + setPackageType(newPackageInfo?.type || defaultPackageType); + setInstalled(Boolean(selectedPackage)); + + } + + function updateSelectedPackageType(e) { + setPackageType(e.target.value); + } + + function validateProjectName() { + setProjectNameIsValid(isValidProjectName(projectName)); + } + + useEffect(updatePackageInfo, [ selectedPackage, defaultPackageType ]); + useEffect(validateProjectName, [ projectName ]); + + return ( + +
    Package Install
    +
    + { + packageTypes.map((pkgType, index) => ( +
    + { pkgType === 'npm' && } + { pkgType === 'github' && } + { pkgType === 'url' && } + +
    + )) + } +
    +
    + +
    + + 0 + }) + } + title="enter a name for this package" + value={ projectName } + placeholder="Project name" + onChange={ + (e) => { + setProjectName(e.target.value); + setProjectNameIsValid(isValidProjectName(e.target.value)); + } + }/> +
    + + { + (!projectNameIsValid && projectName.length > 0) && + + + Your project name must only contain alphanumerics, dashes or underscores. + + } +
    + { + packageType === 'npm' && + + } + { + packageType === 'github' && + + } + { + packageType === 'url' && + + } +
    +
    + + { + setIsValidPackageUrl(e.target.value.length === 0 || isValidTarballUrl(e.target.value)); + setPackageUrl(e.target.value); + } + } + placeholder="url to gzipped tarball" /> +
    + ); + +} + diff --git a/src/components/shared/webide/windows/index.js b/src/components/shared/webide/windows/index.js new file mode 100644 index 000000000..8ddbb9780 --- /dev/null +++ b/src/components/shared/webide/windows/index.js @@ -0,0 +1,11 @@ +export { default as DefaultWindow } from './DefaultWindow'; +export { default as PackageInstallWindow } from './PackageInstallWindow'; +export { default as GithubRepoSelector } from './GithubRepoSelector'; +export { default as NpmPackageSelector } from './NpmPackageSelector'; +export { default as UrlInstallField } from './UrlInstallField'; +export { default as NameProjectWindow } from './NameProjectWindow'; +export { default as NameProjectFolderWindow } from './NameProjectFolderWindow'; +export { default as NameFileWindow } from './NameFileWindow'; +export { default as DeleteFileWindow } from './DeleteFileWindow'; +export { default as DeleteFolderWindow } from './DeleteFolderWindow'; +export { default as DeletePackageWindow } from './DeletePackageWindow'; diff --git a/src/components/shared/webide/windows/lib.js b/src/components/shared/webide/windows/lib.js new file mode 100644 index 000000000..a751a461f --- /dev/null +++ b/src/components/shared/webide/windows/lib.js @@ -0,0 +1,117 @@ +export function isValidProjectName(name) { + return /^[a-zA-Z0-9-_]+$/.test(name); +} + +export async function ensureRepoExists(user, repo) { + + try { + + const response = await fetch(`https://api.github.com/repos/${user}/${repo}`); + const data = await response.json(); + + return response.status < 400 ? data.name : null; + + } catch(e) { + return null; + } + +} + +export async function getGithubTags(user, repo) { + + const response = await fetch(`https://api.github.com/repos/${user}/${repo}/git/refs/tags`); + + if (response.status < 400) { + const tagData = await response.json(); + return tagData.map(tag => tag.ref.split('/').slice(-1)[0]); + } + + return []; + +} + +export async function findNpmPackageName(query) { + + const response = await fetch(`https://registry.npmjs.org/-/v1/search?text=${query}`); + const packages = await response.json(); + const pkg = packages.objects.find(p => p.package.name === query); + + return pkg?.package?.name; + +} + +export async function getNpmDistTags(packageName) { + + // searching for a non-existent package via https://registry.npmjs.org/ will throw a cors error + // so instead, we search for repo using api /search endpoint, compare desired package name + // against the returned results array. If one exactly matches, that package exists. + // When the package exists, we can then look it up against the registry by its package name (avoiding cors error) + // and grab the resulting 'dist-tags' property from the returned payload. + + const packageResponse = await fetch(`https://registry.npmjs.org/${packageName}`); + const packageResponseData = await packageResponse.json(); + + return packageResponseData['dist-tags']; + +} + +export function parsePackageType(pkg) { + + if (!pkg) { + return null; + } + + const meta = { + type: null, + user: null, + repo: null, + url: null, + package: null, + tag: null + }; + + if (pkg.url.match('://')) { + + // it's a url + meta.url = pkg.url; + meta.type = 'url'; + + } else if (pkg.url.match('semver:')) { + // it's a github repo + const [ user, repo, semverTag ] = pkg.url.split(/[/#]/); + meta.type = 'github'; + meta.user = user; + meta.repo = repo; + meta.tag = semverTag.replace('semver:',''); + } else { + + meta.type = 'npm'; + const parts = pkg.url.split('/'); + + // TODO: what should this format be? + // what does the form need? + + if (parts.length === 1) { + // no scope, e.g harperdb[@2], not @harperdb/harperdb[@2] + + const [ p, tag ] = parts[0].split('@'); + + meta.package = p; + meta.tag = tag; + + } else if (parts.length === 2) { + // has @scope, e.g @harperdb/harperdb[@2] + + const [ ,pkgAndTag ] = parts; + const [ p, tag ] = pkgAndTag.split('@'); + + meta.package = p; + meta.tag = tag; + + } + + } + + return meta; + +} diff --git a/src/functions/api/instance/addComponent.js b/src/functions/api/instance/addComponent.js new file mode 100644 index 000000000..4995f4262 --- /dev/null +++ b/src/functions/api/instance/addComponent.js @@ -0,0 +1,11 @@ +import queryInstance from '../queryInstance'; + +export default async ({ auth, url, project }) => + queryInstance({ + operation: { + operation: 'add_component', + project + }, + auth, + url, + }); diff --git a/src/functions/api/instance/deployComponent.js b/src/functions/api/instance/deployComponent.js new file mode 100644 index 000000000..d9955d3f8 --- /dev/null +++ b/src/functions/api/instance/deployComponent.js @@ -0,0 +1,32 @@ +import queryInstance from '../queryInstance'; + +export default async ({ auth, url, project, packageUrl, payload }) => { + + + if (payload) { + + return queryInstance({ + auth, + url, + operation: { + operation: 'deploy_component', + project, + payload + } + }); + + } + + return queryInstance({ + auth, + url, + operation: { + operation: 'deploy_component', + project, + package: packageUrl + } + }); + + + +} diff --git a/src/functions/api/instance/dropComponent.js b/src/functions/api/instance/dropComponent.js new file mode 100644 index 000000000..df7cf34d6 --- /dev/null +++ b/src/functions/api/instance/dropComponent.js @@ -0,0 +1,12 @@ +import queryInstance from '../queryInstance'; + +export default async ({ auth, url, project, file }) => + queryInstance({ + operation: { + operation: 'drop_component', + project, + file + }, + auth, + url, + }); diff --git a/src/functions/api/instance/getComponentFile.js b/src/functions/api/instance/getComponentFile.js new file mode 100644 index 000000000..34860d026 --- /dev/null +++ b/src/functions/api/instance/getComponentFile.js @@ -0,0 +1,12 @@ +import queryInstance from '../queryInstance'; + +export default async ({ auth, url, project, file }) => + queryInstance({ + operation: { + operation: 'get_component_file', + project, + file + }, + auth, + url, + }); diff --git a/src/functions/api/instance/getComponents.js b/src/functions/api/instance/getComponents.js new file mode 100644 index 000000000..e5490177a --- /dev/null +++ b/src/functions/api/instance/getComponents.js @@ -0,0 +1,51 @@ +import { v4 as uuid } from 'uuid'; +import queryInstance from '../queryInstance'; + +// this 'addMetadata' logic probably belongs in src/functions/instance +// by convention +function addMetadata(fileTree, path, rootDir, readOnly=false) { + + if (!fileTree || !fileTree.entries) { + return; + } + + if (path === rootDir) { + fileTree.path = rootDir; + fileTree.key = uuid(); + } + + for (const entry of fileTree.entries) { + + /* + * adds 3 properties to directory entry: + * - project, which is the dir under component root on the instance + * - path, which is the file path relative to the project + * - unique key for react dynamic list optimization + */ + + const newPath = `${path}/${entry.name}`; + const [ , project ] = newPath.split('/'); + entry.project = project; + entry.path = newPath; + entry.key = uuid(); + entry.readOnly = readOnly || !!entry.package; + + addMetadata(entry, newPath, rootDir, entry.readOnly); + + }; + +} + +export default async ({ auth, url }) => { + + const fileTree = await queryInstance({ + operation: { operation: 'get_components' }, + auth, + url, + }); + + addMetadata(fileTree, fileTree.name, fileTree.name, false); + + return fileTree; + +} diff --git a/src/functions/api/instance/packageComponent.js b/src/functions/api/instance/packageComponent.js new file mode 100644 index 000000000..28762e5ca --- /dev/null +++ b/src/functions/api/instance/packageComponent.js @@ -0,0 +1,12 @@ +import queryInstance from '../queryInstance'; + +export default async ({ auth, url, project, skip_node_modules=true }) => + queryInstance({ + auth, + url, + operation: { + operation: 'package_component', + project, + skip_node_modules + } + }); diff --git a/src/functions/api/instance/setComponentFile.js b/src/functions/api/instance/setComponentFile.js new file mode 100644 index 000000000..bd8de722b --- /dev/null +++ b/src/functions/api/instance/setComponentFile.js @@ -0,0 +1,13 @@ +import queryInstance from '../queryInstance'; + +export default async ({ auth, url, project, file, payload }) => + queryInstance({ + operation: { + operation: 'set_component_file', + project, + file, + payload + }, + auth, + url, + }); diff --git a/src/functions/api/instance/updateSystemInfo.js b/src/functions/api/instance/updateSystemInfo.js index cfb0d4869..8e4181806 100644 --- a/src/functions/api/instance/updateSystemInfo.js +++ b/src/functions/api/instance/updateSystemInfo.js @@ -52,6 +52,7 @@ export default async ({ auth, url, signal, refresh, is_local, cachedSystemInfo, }); } + // MEMORY const totalMemory = skip.includes('memory') ? parseFloat(cachedSystemInfo.totalMemory) : diff --git a/src/functions/instance/clustering/datatableRows.js b/src/functions/instance/clustering/datatableRows.js index 480b513e2..8074eeb6b 100644 --- a/src/functions/instance/clustering/datatableRows.js +++ b/src/functions/instance/clustering/datatableRows.js @@ -13,7 +13,7 @@ export default ({ auth, url, buildNetwork, setLoading }) => [ }) =>
    {instance_name}
    , }, { - Header: 'schema', + Header: 'schema', // change to 'database' if instance version is 4.2+ and clustering available in studio cloud instances. Cell: ({ row: { original: { is_first_schema, schema }, diff --git a/src/functions/instance/functions/buildCustomFunctions.js b/src/functions/instance/functions/buildCustomFunctions.js index 4accaf9b6..7a7a65983 100644 --- a/src/functions/instance/functions/buildCustomFunctions.js +++ b/src/functions/instance/functions/buildCustomFunctions.js @@ -1,10 +1,16 @@ import instanceState from '../../state/instanceState'; import customFunctionsStatus from '../../api/instance/customFunctionsStatus'; import getCustomFunctions from '../../api/instance/getCustomFunctions'; +import getComponents from '../../api/instance/getComponents'; + const buildCustomFunctions = async ({ auth, url }) => { const { is_enabled, port, directory, error, message } = await customFunctionsStatus({ auth, url }); - const endpoints = is_enabled ? await getCustomFunctions({ auth, url }) : []; + const fileTree = await getComponents({ auth, url }); + + // TODO: [] should be {} + // TODO: rename endpoints to file tree or something more descriptive + const endpoints = is_enabled ? await getCustomFunctions({ auth, url }) : {}; const custom_functions = { is_enabled, @@ -13,6 +19,7 @@ const buildCustomFunctions = async ({ auth, url }) => { directory, error, message, + fileTree }; instanceState.update((s) => { diff --git a/src/index.js b/src/index.js index 6c8077e2f..c6ff3e3ef 100644 --- a/src/index.js +++ b/src/index.js @@ -4,8 +4,6 @@ import { BrowserRouter } from 'react-router-dom'; import { positions, Provider } from 'react-alert'; import { Elements } from '@stripe/react-stripe-js'; -// incompatible with react-router-dom v6 - import stripePromise from './functions/stripe/stripePromise'; import App from './components/App'; import AlertTemplate from './components/shared/Alert';