diff --git a/.github/workflows/test-storage-on-demand.yml b/.github/workflows/test-storage-on-demand.yml new file mode 100644 index 0000000000..da4cd43584 --- /dev/null +++ b/.github/workflows/test-storage-on-demand.yml @@ -0,0 +1,61 @@ +name: Cypress tests - Storage (On Demand) +on: [workflow_dispatch] +jobs: + cypress-run: + runs-on: ubuntu-latest + container: cypress/browsers:node14.17.0-chrome91-ff89 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - uses: actions/cache@v2 + id: yarn-build-cache + with: + path: | + **/node_modules + ~/.cache/Cypress + **/build + key: ${{ runner.os }}-node_modules-build-storage-${{ hashFiles('./yarn.lock') }} + restore-keys: | + ${{ runner.os }}-node_modules-build- + + # Install NPM dependencies, cache them correctly + # and run all Cypress tests + - name: Cypress run + uses: cypress-io/github-action@v2 + env: + REACT_APP_BLOCKNATIVE_ID: ${{ secrets.GH_REACT_APP_BLOCKNATIVE_ID }} + REACT_APP_FILES_VERIFIER_NAME: ${{ secrets.GH_REACT_APP_FILES_VERIFIER_NAME }} + REACT_APP_FILES_UUID_VERIFIER_NAME: 'chainsafe-uuid-testnet' + REACT_APP_TEST: 'true' + DEBUG: '@cypress/github-action' + with: + start: yarn start:storage-ui + # wait for 10min for the server to be ready + wait-on: 'npx wait-on --timeout 600000 http://localhost:3000' + # custom test command to run + command: yarn test:ci:storage-ui + # store the screenshots if the tests fail + - name: Store screenshots + uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-screenshots + path: packages/storage-ui/cypress/screenshots + # store the videos if the tests fail + # - name: Store videos + # uses: actions/upload-artifact@v1 + # if: failure() + # with: + # name: cypress-videos + # path: packages/storage-ui/cypress/videos + + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2.2.0 + env: + SLACK_TITLE: 'Storage UI Test Suite On-Demand Result:' + SLACK_MESSAGE: ${{ job.status }} + SLACK_COLOR: ${{ job.status }} + MSG_MINIMAL: actions url + SLACK_WEBHOOK: ${{ secrets.SLACK_UI_WEBHOOK }} + SLACK_FOOTER: 'Test run ${{ github.run_number }} was executed on branch: ${{ github.ref }}' diff --git a/packages/common-components/package.json b/packages/common-components/package.json index 3f1954a0c1..b474fdaa60 100644 --- a/packages/common-components/package.json +++ b/packages/common-components/package.json @@ -37,8 +37,7 @@ "@material-ui/styles": ">4.0.0", "formik": "^2.2.5", "react": ">16.8.0", - "react-dom": ">16.8.0", - "react-toast-notifications": ">2.4.0" + "react-dom": ">16.8.0" }, "devDependencies": { "@babel/core": "^7.12.10", @@ -56,7 +55,6 @@ "@svgr/webpack": "^5.5.0", "@types/react-blockies": "^1.4.0", "@types/react-router-dom": "^5.1.6", - "@types/react-toast-notifications": "^2.4.0", "babel-loader": "8.1.0", "fork-ts-checker-webpack-plugin": "^6.0.5", "formik": "^2.2.5", @@ -64,7 +62,6 @@ "react-blockies": "^1.4.1", "react-dom": "16.14.0", "react-router-dom": "^5.2.0", - "react-toast-notifications": "^2.4.0", "rollup": "2.34.2", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^3.1.8", diff --git a/packages/common-components/rollup.config.js b/packages/common-components/rollup.config.js index b9323226bb..05dd9c4f9b 100644 --- a/packages/common-components/rollup.config.js +++ b/packages/common-components/rollup.config.js @@ -39,7 +39,6 @@ export default { "react-dom", "@material-ui/styles", "formik", - "react-toast-notifications", "@chainsafe/common-theme" ] } diff --git a/packages/common-components/src/Breadcrumb/Breadcrumb.tsx b/packages/common-components/src/Breadcrumb/Breadcrumb.tsx index e6072e3aa8..ddc8501af0 100644 --- a/packages/common-components/src/Breadcrumb/Breadcrumb.tsx +++ b/packages/common-components/src/Breadcrumb/Breadcrumb.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from "react" +import React, { Fragment, useMemo } from "react" import clsx from "clsx" import { ITheme, makeStyles, createStyles } from "@chainsafe/common-theme" import { HomeIcon } from "../Icons" @@ -22,6 +22,7 @@ export type BreadcrumbProps = { homeActive?: boolean className?: string showDropDown?: boolean + maximumCrumbs?: number } const useStyles = makeStyles( @@ -88,6 +89,9 @@ const useStyles = makeStyles( menuTitle: { padding: `0px ${constants.generalUnit * 1.5}px 0px ${constants.generalUnit * 0.5}px` }, + menuOptions: { + zIndex: zIndex?.layer1 + }, menuIcon: { width: 12, height: 12, @@ -137,10 +141,13 @@ const Breadcrumb = ({ homeRef, homeActive, className, - showDropDown + showDropDown, + maximumCrumbs }: BreadcrumbProps) => { const classes = useStyles() + const maximumCrumbsBeforeCollapse = useMemo(() => maximumCrumbs || 3, [maximumCrumbs]) + const generateFullCrumbs = (crumbs: Crumb[]) => { return crumbs.map((crumb: Crumb, index: number) => ( @@ -158,6 +165,7 @@ const Breadcrumb = ({ animation="rotate" classNames={{ item: classes.menuItem, + options: classes.menuOptions, title: classes.menuTitle, icon: classes.menuIcon, titleText: classes.menuTitleText @@ -170,16 +178,16 @@ const Breadcrumb = ({ } const generateCrumbs = () => { - if (crumbs.length < 3 || !showDropDown) { + if (crumbs.length < (maximumCrumbs || 3) || !showDropDown) { return generateFullCrumbs(crumbs) } else { - const dropdownCrumbs = crumbs.slice(0, crumbs.length - 1) - const lastCrumb = crumbs[crumbs.length - 1] + const dropdownCrumbs = crumbs.slice(0, crumbs.length - (maximumCrumbsBeforeCollapse - 2)) + const lastCrumbs = crumbs.slice(crumbs.length - (maximumCrumbsBeforeCollapse - 2), crumbs.length) return ( <> {generateDropdownCrumb(dropdownCrumbs)}
- + {generateFullCrumbs(lastCrumbs)} ) } diff --git a/packages/common-components/src/CheckboxInput/CheckboxInput.tsx b/packages/common-components/src/CheckboxInput/CheckboxInput.tsx index 8a1f4375a3..9a7d7a18d0 100644 --- a/packages/common-components/src/CheckboxInput/CheckboxInput.tsx +++ b/packages/common-components/src/CheckboxInput/CheckboxInput.tsx @@ -106,7 +106,8 @@ interface ICheckboxProps extends Omit, "value" error?: string value: boolean indeterminate?: boolean - onChange: (event: FormEvent) => void + onChange?: (event: FormEvent) => void + onClick?: (event: React.MouseEvent) => void testId?: string } @@ -114,6 +115,7 @@ const CheckboxInput: React.FC = ({ className, label, onChange, + onClick, disabled, indeterminate = false, value, @@ -124,7 +126,7 @@ const CheckboxInput: React.FC = ({ const classes = useStyles(props) const handleChange = (event: any) => { - !disabled && onChange(event) + !disabled && onChange && onChange(event) } return ( @@ -145,6 +147,7 @@ const CheckboxInput: React.FC = ({ ["disabled"]: disabled, ["indeterminate"]: indeterminate })} + onClick={onClick} >
diff --git a/packages/common-components/src/CheckboxInput/FormikCheckboxInput.tsx b/packages/common-components/src/CheckboxInput/FormikCheckboxInput.tsx index 2dd8917103..3f00239154 100644 --- a/packages/common-components/src/CheckboxInput/FormikCheckboxInput.tsx +++ b/packages/common-components/src/CheckboxInput/FormikCheckboxInput.tsx @@ -3,13 +3,14 @@ import { useField } from "formik" import CheckboxInput from "./CheckboxInput" interface IFormikCheckboxProps - extends Omit, "label"> { + extends Omit, "label" | "onClick"> { className?: string name: string label?: string | ReactNode + onClick?: (event: React.MouseEvent) => void } -const FormikCheckboxInput: React.FC = ({ name, onChange, ...props }) => { +const FormikCheckboxInput: React.FC = ({ name, onChange, onClick, ...props }) => { const [field, meta, helpers] = useField(name) const handleChange = (event: React.FormEvent) => { @@ -20,6 +21,7 @@ const FormikCheckboxInput: React.FC = ({ name, onChange, . return ( }) ) -interface FileWithPath extends File { - path?: string -} - interface IFileInputProps extends DropzoneOptions { className?: string variant?: "dropzone" | "filepicker" @@ -143,14 +139,14 @@ const FileInput = ({ const classes = useStyles() const [previews, setPreviews] = useState([]) const [errors, setErrors] = useState([]) - const [{ value }, meta, helpers] = useField>(name) + const [{ value }, meta, helpers] = useField(name) useEffect(() => { onFileNumberChange && onFileNumberChange(value.length) }, [onFileNumberChange, value.length]) const onDrop = useCallback( - async (acceptedFiles: Array, fileRejections: FileRejection[]) => { + async (acceptedFiles: FileWithPath[], fileRejections: FileRejection[]) => { const filtered = acceptedFiles.filter((file) => maxFileSize ? file.size <= maxFileSize : true ) @@ -165,6 +161,7 @@ const FileInput = ({ ) ) } + helpers.setValue([...value, ...filtered]) if (fileRejections.length > 0) { @@ -277,11 +274,17 @@ const FileInput = ({ )} {(meta.error || errors.length > 0) && (
    -
  • {meta.error}
  • +
  • + {meta.error} +
  • {errors.map((error, i) => (
  • {error}
  • diff --git a/packages/common-components/src/Icons/svgs/arrow-left.svg b/packages/common-components/src/Icons/svgs/arrow-left.svg index 1876a03047..23f463f744 100644 --- a/packages/common-components/src/Icons/svgs/arrow-left.svg +++ b/packages/common-components/src/Icons/svgs/arrow-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/common-components/src/Icons/svgs/arrow-right.svg b/packages/common-components/src/Icons/svgs/arrow-right.svg index c5e30df9cb..b8854961b4 100644 --- a/packages/common-components/src/Icons/svgs/arrow-right.svg +++ b/packages/common-components/src/Icons/svgs/arrow-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/common-components/src/Icons/svgs/caret-right.svg b/packages/common-components/src/Icons/svgs/caret-right.svg index 28b96ed2be..7a6c824cd4 100644 --- a/packages/common-components/src/Icons/svgs/caret-right.svg +++ b/packages/common-components/src/Icons/svgs/caret-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/common-components/src/Pagination/Pagination.tsx b/packages/common-components/src/Pagination/Pagination.tsx new file mode 100644 index 0000000000..3c4a701bcd --- /dev/null +++ b/packages/common-components/src/Pagination/Pagination.tsx @@ -0,0 +1,86 @@ +import React from "react" +import { createStyles, makeStyles } from "@chainsafe/common-theme" +import { CaretLeftIcon, CaretRightIcon } from "../Icons" +import { ITheme } from "@chainsafe/common-theme" +import { Button } from "../Button" +import { Typography } from "../Typography" + +const useStyles = makeStyles(({ constants, palette }: ITheme) => { + return createStyles({ + root: { + display: "flex", + alignItems: "center", + margin: `${constants.generalUnit}px 0` + }, + nextButton: { + marginLeft: constants.generalUnit * 1.5 + }, + previousButton: { + marginRight: constants.generalUnit * 1.5 + }, + icons: { + fill: palette.additional["gray"][9] + } + }) +}) + +export interface IPaginationProps { + pageNo?: number + totalPages?: number + onNextClick?: () => void + onPreviousClick?: () => void + loadingNext?: boolean + loadingPrevious?: boolean + showPageNumbers?: boolean + isNextDisabled?: boolean + isPreviousDisabled?: boolean +} + +const Pagination: React.FC = ({ + pageNo, + totalPages, + onNextClick, + onPreviousClick, + loadingNext, + loadingPrevious, + showPageNumbers, + isNextDisabled, + isPreviousDisabled +}) => { + const classes = useStyles() + + return ( +
    + + {!!showPageNumbers && pageNo && + + {`Page ${pageNo} ${totalPages ? `of ${totalPages}` : "" }`} + + } + +
    + ) +} + +export default Pagination \ No newline at end of file diff --git a/packages/common-components/src/Pagination/index.ts b/packages/common-components/src/Pagination/index.ts new file mode 100644 index 0000000000..a1e07fbfb3 --- /dev/null +++ b/packages/common-components/src/Pagination/index.ts @@ -0,0 +1 @@ +export { default as Pagination, IPaginationProps } from "./Pagination" diff --git a/packages/common-components/src/RadioInput/FormikRadioInput.tsx b/packages/common-components/src/RadioInput/FormikRadioInput.tsx index 6fa9e7d5e3..1e550d57a9 100644 --- a/packages/common-components/src/RadioInput/FormikRadioInput.tsx +++ b/packages/common-components/src/RadioInput/FormikRadioInput.tsx @@ -7,6 +7,7 @@ interface IFormikRadioInputProps extends React.HTMLProps { name: string label?: string id: string + testId: string } const FormikRadioInput: React.FC = ({ @@ -14,6 +15,7 @@ const FormikRadioInput: React.FC = ({ onChange, id, label, + testId, ...props }) => { const [field, meta, helpers] = useField(name) @@ -32,6 +34,7 @@ const FormikRadioInput: React.FC = ({ value={id} checked={field.value === id} label={label} + testId={testId} /> ) } diff --git a/packages/common-components/src/Toasts/ToastContext.tsx b/packages/common-components/src/Toasts/ToastContext.tsx index b9cc6e22bd..4538b283b6 100644 --- a/packages/common-components/src/Toasts/ToastContext.tsx +++ b/packages/common-components/src/Toasts/ToastContext.tsx @@ -88,12 +88,11 @@ interface ToastContext { addToast: (toastParams: ToastParams) => string updateToast: (toastId: string, toastParams: ToastParams, startDismissal?: boolean) => void removeToast: (toastId: string) => void + removeAllToasts: () => void toasts: Toast[] } -const ToastContext = React.createContext( - undefined -) +const ToastContext = React.createContext(undefined) const ToastProvider = ({ children, @@ -111,6 +110,16 @@ const ToastProvider = ({ setToastQueue(toasts.current) }, [toasts]) + const removeAllToasts = useCallback(() => { + // cancel any pending progress such as upload/downloads etc.. + toasts.current.forEach((toast) => { + toast.onProgressCancel && toast.onProgressCancel() + }) + + toasts.current = [] + setToastQueue(toasts.current) + }, [toasts]) + const addToast = useCallback((toastParams: ToastParams) => { const id = uuidv4() toasts.current = [ @@ -159,6 +168,7 @@ const ToastProvider = ({ addToast, updateToast, removeToast, + removeAllToasts, toasts: toastQueue }} > @@ -184,8 +194,7 @@ const ToastProvider = ({ ))} ) - )) - } + ))} {children} ) diff --git a/packages/common-components/src/index.ts b/packages/common-components/src/index.ts index 561e07dde4..4caa364b35 100644 --- a/packages/common-components/src/index.ts +++ b/packages/common-components/src/index.ts @@ -20,6 +20,7 @@ export * from "./Icons" export * from "./Modal" export * from "./NumberInput" export * from "./MenuDropdown" +export * from "./Pagination" export * from "./Paper" export * from "./ProgressBar" export * from "./RadioInput" diff --git a/packages/common-components/src/stories/Breadcrumb.stories.tsx b/packages/common-components/src/stories/Breadcrumb.stories.tsx index 46db1603d6..f5582bd757 100644 --- a/packages/common-components/src/stories/Breadcrumb.stories.tsx +++ b/packages/common-components/src/stories/Breadcrumb.stories.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from "react" +import React from "react" import { action } from "@storybook/addon-actions" -import { boolean, withKnobs, text } from "@storybook/addon-knobs" +import { boolean, withKnobs, text, number } from "@storybook/addon-knobs" import { Breadcrumb } from "../Breadcrumb" export default { @@ -15,30 +15,51 @@ export const actionsData = { homeClicked: action("Clicked link") } -export const BreadcrumbStory = (): React.ReactNode => { - const crumbs = useMemo(() => ([ - { - text: text("breadcrumb 2", "Level 1 Clickable"), - onClick: () => actionsData.linkClick(), - active: boolean("breadcrumb-2-active", false) - }, - { - text: "Level 2" - }, - { - text: "Level 3" - }, - { - text: "Level 4" - } - ]), []) +const crumbs = [ + { + text: text("breadcrumb 2", "Level 1 Clickable"), + onClick: () => actionsData.linkClick(), + active: boolean("breadcrumb-2-active", false) + }, + { + text: "Level 2" + }, + { + text: "Level 3" + }, + { + text: "Level 4" + }, + { + text: "Level 5" + }, + { + text: "Level 6" + } +] +export const BreadcrumbWithDropdown = (): React.ReactNode => { + + return ( + <> + actionsData.homeClicked()} + homeActive={boolean("home-active", false)} + showDropDown + crumbs={crumbs} + hideHome={boolean("hide home", false)} + maximumCrumbs={number("maximum crumbs", 3)} + + /> + + )} + +export const Breadcrumbs = (): React.ReactNode => { return ( <> actionsData.homeClicked()} homeActive={boolean("home-active", false)} - showDropDown={boolean("show dropdown", true)} crumbs={crumbs} hideHome={boolean("hide home", false)} /> diff --git a/packages/common-components/src/stories/Pagination.stories.tsx b/packages/common-components/src/stories/Pagination.stories.tsx new file mode 100644 index 0000000000..19a2e191ba --- /dev/null +++ b/packages/common-components/src/stories/Pagination.stories.tsx @@ -0,0 +1,23 @@ +import React from "react" +import { withKnobs, boolean, number } from "@storybook/addon-knobs" +import { Pagination } from "../Pagination" + +export default { + title: "Pagination", + component: Pagination, + decorators: [withKnobs] +} + +export const PaginationDemo = (): React.ReactNode => { + return ( + + ) +} diff --git a/packages/files-ui/.env.example b/packages/files-ui/.env.example index 45101c9bd5..0a83db94cb 100644 --- a/packages/files-ui/.env.example +++ b/packages/files-ui/.env.example @@ -6,7 +6,6 @@ REACT_APP_API_URL=https://stage-api.chainsafe.io/api/v1 REACT_APP_STRIPE_PK= REACT_APP_SENTRY_DSN_URL= REACT_APP_SENTRY_ENV=development -REACT_APP_HOTJAR_ID= # Get your ID on Blocknative: https://explorer.blocknative.com/account REACT_APP_BLOCKNATIVE_ID= REACT_APP_GOOGLE_CLIENT_ID=939164021653-lb5eiquuatf877em98bpi8v360p5vcs4.apps.googleusercontent.com diff --git a/packages/files-ui/cypress/support/commands.ts b/packages/files-ui/cypress/support/commands.ts index c8f9e4b0e6..f657b23474 100644 --- a/packages/files-ui/cypress/support/commands.ts +++ b/packages/files-ui/cypress/support/commands.ts @@ -147,7 +147,7 @@ Cypress.Commands.add( } ) -Cypress.Commands.add("safeClick", { prevSubject: "element" }, ($element?: JQuery) => { +Cypress.Commands.add("safeClick", { prevSubject: "element" }, ($element, _, expectDisable = false) => { const click = ($el: JQuery) => $el.trigger("click") return cy @@ -155,7 +155,10 @@ Cypress.Commands.add("safeClick", { prevSubject: "element" }, ($element?: JQuery .should("be.visible") .should("be.enabled") .pipe(click) - .should($el => expect($el).to.not.be.visible) + .should($el => expectDisable + ? expect($el).to.be.disabled + : expect($el).to.not.be.visible + ) }) Cypress.Commands.add("iframeLoaded", { prevSubject: "element" }, ($iframe?: JQuery): any => { @@ -235,7 +238,7 @@ declare global { * https://github.com/cypress-io/cypress/issues/7306 * */ - safeClick: ($element?: JQuery) => Chainable + safeClick: ($element?: JQuery, expectDisable?: boolean) => Chainable /** * Clear a bucket. diff --git a/packages/files-ui/cypress/support/index.ts b/packages/files-ui/cypress/support/index.ts index 5463959dd3..81ed372a76 100644 --- a/packages/files-ui/cypress/support/index.ts +++ b/packages/files-ui/cypress/support/index.ts @@ -35,5 +35,18 @@ if(app != null && !app.document.head.querySelector("[data-hide-command-log-reque app.document.head.appendChild(style) } +before(() => { + // grant clipboard read permissions to the browser + cy.wrap( + Cypress.automation("remote:debugger:protocol", { + command: "Browser.grantPermissions", + params: { + permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], + origin: window.location.origin, + } + }) + ) +}) + // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/packages/files-ui/cypress/support/page-objects/authenticationPage.ts b/packages/files-ui/cypress/support/page-objects/authenticationPage.ts index f74f160467..4dae720c4e 100644 --- a/packages/files-ui/cypress/support/page-objects/authenticationPage.ts +++ b/packages/files-ui/cypress/support/page-objects/authenticationPage.ts @@ -13,6 +13,9 @@ export const authenticationPage = { showMoreButton: () => cy.get("div.svelte-q1527 > .bn-onboard-custom"), detectedWallet: () => cy.get(":nth-child(3) > .bn-onboard-custom > span.svelte-1799bj2"), web3SignInButton: () => cy.get("[data-cy=button-sign-in-with-web3]"), + privacyPolicyLink: () => cy.get("[data-cy=link-privacy-policy]"), + termsAndConditionsLink: () => cy.get("[data-cy=link-terms-and-conditions]"), + learnMoreAboutChainsafeLink: () => cy.get("[data-cy=link-learn-more-about-chainsafe]"), // sign in section elements loginPasswordButton: () => cy.get("[data-cy=button-login-password]", { timeout: 20000 }), diff --git a/packages/files-ui/cypress/support/page-objects/modals/fileInfoModal.ts b/packages/files-ui/cypress/support/page-objects/modals/fileInfoModal.ts new file mode 100644 index 0000000000..76308857ec --- /dev/null +++ b/packages/files-ui/cypress/support/page-objects/modals/fileInfoModal.ts @@ -0,0 +1,9 @@ +export const fileInfoModal = { + body: () => cy.get("[data-cy=modal-container-info]", { timeout: 10000 }), + closeButton: () => cy.get("[data-cy=button-info-close]"), + cidLabel: () => cy.get("[data-cy=label-info-cid]"), + fileSizeLabel: () => cy.get("[data-cy=label-info-file-size]"), + dateUploadedLabel: () => cy.get("[data-cy=label-info-date-uploaded]"), + nameLabel: () => cy.get("[data-cy=label-info-name]"), + decryptionKeyLabel: () => cy.get("[data-cy=label-info-decryption-key]") +} \ No newline at end of file diff --git a/packages/files-ui/cypress/support/page-objects/modals/fileUploadModal.ts b/packages/files-ui/cypress/support/page-objects/modals/fileUploadModal.ts index 233b1960af..9128b27ee0 100644 --- a/packages/files-ui/cypress/support/page-objects/modals/fileUploadModal.ts +++ b/packages/files-ui/cypress/support/page-objects/modals/fileUploadModal.ts @@ -1,8 +1,26 @@ export const fileUploadModal = { - body: () => cy.get("[data-cy=form-upload-file] input", { timeout: 20000 }), + body: () => cy.get("[data-cy=form-upload-file] input"), cancelButton: () => cy.get("[data-testid=button-cancel-upload]"), fileList: () => cy.get("[data-testid=list-fileUpload] li"), removeFileButton: () => cy.get("[data-testid=button-remove-from-file-list]"), - uploadButton: () => cy.get("[data-testid=button-start-upload]", { timeout: 10000 }), - uploadDropzone : () => cy.get("[data-testid=input-file-dropzone-fileUpload]") + uploadButton: () => cy.get("[data-testid=button-start-upload]"), + uploadDropzone : () => cy.get("[data-testid=input-file-dropzone-fileUpload]"), + errorLabel: () => cy.get("[data-testid=meta-error-message-fileUpload]"), + + // helper function only used when needing to invoke error labels + attachFileForAutomation() { + this.body().attachFile("../fixtures/uploadedFiles/text-file.txt") + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000) + this.uploadButton().should("be.visible") + + // we cannot interact with the file picker window in cypress + // the way we attach files is different than a real user + // a click is sometimes necessary to invoke an error + this.uploadButton().then((button) => { + if (button.is(":enabled")) { + this.uploadButton().click() + } + }) + } } diff --git a/packages/files-ui/cypress/tests/file-management-spec.ts b/packages/files-ui/cypress/tests/file-management-spec.ts index 331bcbc4fd..a95a153c73 100644 --- a/packages/files-ui/cypress/tests/file-management-spec.ts +++ b/packages/files-ui/cypress/tests/file-management-spec.ts @@ -13,6 +13,7 @@ import { deleteSuccessToast } from "../support/page-objects/toasts/deleteSuccess import { moveSuccessToast } from "../support/page-objects/toasts/moveSuccessToast" import { recoverSuccessToast } from "../support/page-objects/toasts/recoverSuccessToast" import { uploadCompleteToast } from "../support/page-objects/toasts/uploadCompleteToast" +import { fileInfoModal } from "../support/page-objects/modals/fileInfoModal" describe("File management", () => { @@ -47,6 +48,24 @@ describe("File management", () => { fileUploadModal.body().should("not.exist") }) + it("cannot upload a file if the size exceeds capacity", () => { + // intercept and stub storage data + cy.intercept("GET", "**/buckets/summary", (req) => { + req.on("response", (res) => { + res.body.available_storage = "0" + res.body.total_storage = "107374182400" + res.body.used_storage = "107374181400" + }) + }) + + cy.web3Login() + homePage.uploadButton().click() + fileUploadModal.attachFileForAutomation() + // ensure an error label is present + fileUploadModal.errorLabel().should("be.visible") + fileUploadModal.uploadButton().should("be.disabled") + }) + it("can move a file in and out of a folder", () => { cy.web3Login({ clearCSFBucket: true }) @@ -445,5 +464,43 @@ describe("File management", () => { homePage.fileItemName().should("contain.text", fileNameB) }) }) + + it("can view file information via modal option", () => { + cy.web3Login({ clearCSFBucket: true }) + + // upload a file + homePage.uploadFile("../fixtures/uploadedFiles/text-file.txt") + homePage.fileItemRow().should("have.length", 1) + + // store file name as cypress aliases for later comparison + homePage.fileItemName().eq(0).invoke("text").as("fileNameA") + + // navigate to the info modal for the file + homePage.fileItemKebabButton().first().click() + homePage.infoMenuOption().eq(0).click() + + // ensure all labels on the modal are visible + fileInfoModal.nameLabel().should("be.visible") + fileInfoModal.fileSizeLabel().should("be.visible") + fileInfoModal.dateUploadedLabel().should("be.visible") + fileInfoModal.cidLabel().should("be.visible") + fileInfoModal.decryptionKeyLabel().should("be.visible") + + // ensure the correct file name is being displayed + fileInfoModal.body().should("be.visible") + cy.get("@fileNameA").then((fileNameA) => { + fileInfoModal.nameLabel().should("have.text", fileNameA) + }) + + // ensure the correct CID is being copied to the clipboard + fileInfoModal.cidLabel().click() + cy.window().its("navigator.clipboard").invoke("readText").then((text) => { + fileInfoModal.cidLabel().should("have.text", text) + }) + + // cancel and ensure that the modal is dismissed + fileInfoModal.closeButton().click() + fileInfoModal.body().should("not.exist") + }) }) }) diff --git a/packages/files-ui/cypress/tests/landing-spec.ts b/packages/files-ui/cypress/tests/landing-spec.ts new file mode 100644 index 0000000000..05fca7fe6d --- /dev/null +++ b/packages/files-ui/cypress/tests/landing-spec.ts @@ -0,0 +1,25 @@ +import { authenticationPage } from "../support/page-objects/authenticationPage" +import { localHost } from "../fixtures/loginData" + +describe("Landing", () => { + beforeEach(() => { + cy.visit(localHost) + }) + context("desktop", () => { + + it("can navigate to privacy policy page", () => { + authenticationPage.privacyPolicyLink().invoke('removeAttr', 'target').click() + cy.url().should("include", "/privacy-policy") + }) + + it("can navigate to terms & conditions page", () => { + authenticationPage.termsAndConditionsLink().invoke('removeAttr', 'target').click() + cy.url().should("include", "/terms-of-service") + }) + + it("can navigate to ChainSafe.io from 'Learn more about Chainsafe'", () => { + authenticationPage.learnMoreAboutChainsafeLink().invoke('removeAttr', 'target').click() + cy.url().should("eq", "https://chainsafe.io/") + }) + }) +}) diff --git a/packages/files-ui/cypress/tests/main-navigation-spec.ts b/packages/files-ui/cypress/tests/main-navigation-spec.ts index d3c0943906..e846a16e8b 100644 --- a/packages/files-ui/cypress/tests/main-navigation-spec.ts +++ b/packages/files-ui/cypress/tests/main-navigation-spec.ts @@ -23,10 +23,6 @@ describe("Main Navigation", () => { cy.url().should("include", "/settings") }) - it.skip("can navigate to block survey via send feedback button", () => { - // TODO: Andrew - find a way to check the button link, cypress doesn't support tabs #1084 - }) - it("can navigate to the home page", () => { navigationMenu.homeNavButton() .should("be.visible") diff --git a/packages/files-ui/package.json b/packages/files-ui/package.json index bfb3f33509..b72dba3e2f 100644 --- a/packages/files-ui/package.json +++ b/packages/files-ui/package.json @@ -6,7 +6,7 @@ "@babel/core": "^7.12.10", "@babel/runtime": "^7.0.0", "@chainsafe/browser-storage-hooks": "^1.0.1", - "@chainsafe/files-api-client": "1.18.34", + "@chainsafe/files-api-client": "1.18.35", "@chainsafe/web3-context": "1.3.1", "@emeraldpay/hashicon-react": "^0.5.1", "@lingui/core": "^3.7.2", @@ -50,7 +50,6 @@ "react-pdf": "5.3.0", "react-scripts": "3.4.4", "react-swipeable": "^6.0.1", - "react-toast-notifications": "^2.4.0", "react-qr-code": "2.0.3", "react-zoom-pan-pinch": "^1.6.1", "remark-gfm": "^1.0.0", @@ -74,7 +73,6 @@ "@types/react-beforeunload": "^2.1.0", "@types/react-dom": "^16.9.10", "@types/react-pdf": "^5.0.0", - "@types/react-toast-notifications": "^2.4.0", "@types/yup": "^0.29.9", "@types/zxcvbn": "^4.4.0", "babel-plugin-macros": "^2.8.0", diff --git a/packages/files-ui/src/Components/Layouts/AppNav.tsx b/packages/files-ui/src/Components/Layouts/AppNav.tsx index f6e863b4bb..5651b96017 100644 --- a/packages/files-ui/src/Components/Layouts/AppNav.tsx +++ b/packages/files-ui/src/Components/Layouts/AppNav.tsx @@ -14,7 +14,8 @@ import { UserShareSvg, MenuDropdown, ScrollbarWrapper, - useLocation + useLocation, + useToasts } from "@chainsafe/common-components" import { ROUTE_LINKS } from "../FilesRoutes" import { Trans } from "@lingui/macro" @@ -260,11 +261,13 @@ const AppNav = ({ navOpen, setNavOpen }: IAppNav) => { const { publicKey, isNewDevice, shouldInitializeAccount, logout } = useThresholdKey() const { removeUser, getProfileTitle, profile } = useUser() const location = useLocation() + const { removeAllToasts } = useToasts() const signOut = useCallback(() => { logout() removeUser() - }, [logout, removeUser]) + removeAllToasts() + }, [logout, removeAllToasts, removeUser]) const handleOnClick = useCallback(() => { if (!desktop && navOpen) { diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index bf1172c6ae..e153eb553b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -6,11 +6,14 @@ import DragAndDrop from "../../../Contexts/DnDContext" import { t } from "@lingui/macro" import { CONTENT_TYPES } from "../../../Utils/Constants" import { IFilesTableBrowserProps } from "../../Modules/FileBrowsers/types" -import { useHistory, useLocation, useToasts } from "@chainsafe/common-components" +import { Crumb, useHistory, useLocation, useToasts } from "@chainsafe/common-components" import { extractFileBrowserPathFromURL, getAbsolutePathsFromCids, + getArrayOfPaths, + getURISafePathFromArray, getUrlSafePathWithFile, + joinArrayOfPaths, pathEndingWithSlash } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" @@ -50,6 +53,7 @@ const BinFileBrowser: React.FC = ({ controls = false }: }, [bucket, currentPath, filesApiClient] ) + useEffect(() => { refreshContents(true) }, [bucket, refreshContents]) @@ -122,11 +126,24 @@ const BinFileBrowser: React.FC = ({ controls = false }: [CONTENT_TYPES.Directory]: ["recover", "delete"] }), []) + // Breadcrumbs/paths + const arrayOfPaths = useMemo(() => getArrayOfPaths(currentPath), [currentPath]) + const crumbs: Crumb[] = useMemo(() => arrayOfPaths.map((path, index) => { + return { + text: decodeURIComponent(path), + onClick: () => { + redirect( + ROUTE_LINKS.Bin(getURISafePathFromArray(arrayOfPaths.slice(0, index + 1))) + ) + }, + path: joinArrayOfPaths(arrayOfPaths.slice(0, index + 1)) + }}), [arrayOfPaths, redirect]) + return ( = () => { return } const flattenedFiles = await getFilesFromDataTransferItems(fileItems) - const paths = [...new Set(flattenedFiles.map(f => f.filepath))] - paths.forEach(p => { - uploadFiles(bucket, flattenedFiles.filter(f => f.filepath === p), getPathWithFile(path, p)) - }) + await uploadFiles(bucket, flattenedFiles, path) }, [bucket, accountRestricted, storageSummary, addToast, uploadFiles]) const viewFolder = useCallback((cid: string) => { diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/FileInfoModal.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/FileInfoModal.tsx index 020d7e7e60..d641aaacc9 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/FileInfoModal.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/FileInfoModal.tsx @@ -232,6 +232,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { xs={12} sm={12} className={classes.infoContainer} + data-cy="modal-container-info" >
    @@ -254,6 +255,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { className={classes.subSubtitle} variant="body2" component="p" + data-cy="label-info-name" > {fullFileInfo.content.name} @@ -271,6 +273,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { className={classes.subSubtitle} variant="body2" component="p" + data-cy="label-info-date-uploaded" > {fullFileInfo.content.created_at && dayjs.unix(fullFileInfo.content.created_at).format("DD MMM YYYY h:mm a")} @@ -288,6 +291,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { className={classes.subSubtitle} variant="body2" component="p" + data-cy="label-info-file-size" > {formatBytes(fullFileInfo.content?.size, 2)} @@ -356,6 +360,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { className={classes.subSubtitle} variant="body2" component="p" + data-cy="label-info-cid" > {fullFileInfo.content?.cid} @@ -391,6 +396,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { className={classes.subSubtitle} variant="body2" component="p" + data-cy="label-info-decryption-key" > {bucket?.encryptionKey} @@ -421,6 +427,7 @@ const FileInfoModal = ({ filePath, close }: IFileInfoModuleProps) => { size="large" variant="outline" type="button" + data-cy="button-info-close" > Close diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/ManageSharedFolder.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/ManageSharedFolder.tsx index cf89efa82e..b4cd087909 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/ManageSharedFolder.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/ManageSharedFolder.tsx @@ -197,6 +197,7 @@ const ManageSharedFolder = ({ onClose, bucketToEdit }: ICreateOrManageSharedFold const [suggestedUsers, setSuggestedUsers] = useState([]) const [loadingUsers, setLoadingUsers] = useState(false) const [searchActive, setSearchActive] = useState(false) + const [touchedLinksList, setTouchedLinksList] = useState(false) const onReset = useCallback(() => { setHasPermissionsChanged(false) @@ -219,10 +220,18 @@ const ManageSharedFolder = ({ onClose, bucketToEdit }: ICreateOrManageSharedFold const onEditSharedFolder = useCallback(() => { if (!bucketToEdit) return + + // only sharing link where touched no need to call the api + // just close the modal + if (!hasPermissionsChanged) { + handleClose() + return + } + handleEditSharedFolder(bucketToEdit, sharedFolderReaders, sharedFolderWriters) .catch(console.error) .finally(handleClose) - }, [handleEditSharedFolder, sharedFolderWriters, sharedFolderReaders, handleClose, bucketToEdit]) + }, [bucketToEdit, hasPermissionsChanged, handleEditSharedFolder, sharedFolderReaders, sharedFolderWriters, handleClose]) const onLookupUser = (inputText?: string) => { if (!inputText) return @@ -398,6 +407,7 @@ const ManageSharedFolder = ({ onClose, bucketToEdit }: ICreateOrManageSharedFold setTouchedLinksList(true)} />
    } Update diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFileBrowser.tsx index 236e03331c..6b4896e966 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFileBrowser.tsx @@ -225,10 +225,7 @@ const SharedFileBrowser = () => { return } const flattenedFiles = await getFilesFromDataTransferItems(fileItems) - const paths = [...new Set(flattenedFiles.map(f => f.filepath))] - paths.forEach(p => { - uploadFiles(bucket, flattenedFiles.filter(f => f.filepath === p), getPathWithFile(path, p)) - }) + await uploadFiles(bucket, flattenedFiles, path) }, [bucket, accountRestricted, storageSummary, addToast, uploadFiles]) const bulkOperations: IBulkOperations = useMemo(() => ({ diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFoldersOverview.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFoldersOverview.tsx index bc22e609d7..859cbf0a87 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFoldersOverview.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/SharedFoldersOverview.tsx @@ -38,6 +38,7 @@ const useStyles = makeStyles( root: { position: "relative", [breakpoints.down("md")]: { + marginTop: constants.generalUnit * 4, marginLeft: constants.generalUnit * 2, marginRight: constants.generalUnit * 2, "&.bottomBanner": { @@ -46,6 +47,7 @@ const useStyles = makeStyles( }, [breakpoints.up("md")]: { border: "1px solid transparent", + marginTop: constants.generalUnit * 6, padding: `0 ${constants.generalUnit}px`, borderRadius: constants.generalUnit / 4, minHeight: `calc(100vh - ${Number(constants.contentTopPadding)}px)`, diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/LinkList.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/LinkList.tsx index dd88895b59..d112a050b1 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/LinkList.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/LinkList.tsx @@ -112,9 +112,10 @@ const MAX_LINKS = 2 interface Props { bucketId: string bucketEncryptionKey: string + setTouchedLinksList: () => void } -const LinkList = ({ bucketId, bucketEncryptionKey }: Props) => { +const LinkList = ({ bucketId, bucketEncryptionKey, setTouchedLinksList }: Props) => { const classes = useStyles() const { filesApiClient } = useFilesApi() const [nonces, setNonces] = useState([]) @@ -163,8 +164,9 @@ const LinkList = ({ bucketId, bucketEncryptionKey }: Props) => { .finally(() => { setIsLoadingCreation(false) refreshNonces() + setTouchedLinksList() }) - }, [bucketId, captureEvent, filesApiClient, newLinkPermission, refreshNonces]) + }, [bucketId, captureEvent, filesApiClient, newLinkPermission, refreshNonces, setTouchedLinksList]) return (
    @@ -178,6 +180,7 @@ const LinkList = ({ bucketId, bucketEncryptionKey }: Props) => { bucketEncryptionKey={bucketEncryptionKey} nonce={nonce} data-cy="link-share-folder" + setTouchedLinksList={setTouchedLinksList} /> )}
    diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/SharingLink.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/SharingLink.tsx index 9028201d63..ba21b270a1 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/SharingLink.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/Sharing/SharingLink.tsx @@ -128,9 +128,10 @@ interface Props { nonce: NonceResponse bucketEncryptionKey: string refreshNonces: (hideLoading?: boolean) => void + setTouchedLinksList: () => void } -const SharingLink = ({ nonce, bucketEncryptionKey, refreshNonces }: Props) => { +const SharingLink = ({ nonce, bucketEncryptionKey, refreshNonces, setTouchedLinksList }: Props) => { const classes = useStyles() const { filesApiClient } = useFilesApi() const [link, setLink] = useState("") @@ -165,30 +166,6 @@ const SharingLink = ({ nonce, bucketEncryptionKey, refreshNonces }: Props) => { }) .catch(console.error) - // //Create a textbox field where we can insert text to. - // const copyFrom = document.createElement("textarea") - - // //Set the text content to be the text you wished to copy. - // copyFrom.textContent = link - - // //Append the textbox field into the body as a child. - // //"execCommand()" only works when there exists selected text, and the text is inside - // //document.body (meaning the text is part of a valid rendered HTML element). - // document.body.appendChild(copyFrom) - - // //Select all the text! - // copyFrom.select() - - // //Execute command - // document.execCommand("copy") - - // //(Optional) De-select the text using blur(). - // copyFrom.blur() - - // //Remove the textbox field from the document.body, so no other JavaScript nor - // //other elements can get access to this. - // document.body.removeChild(copyFrom) - setCopied(true) debouncedSwitchCopied() }, [debouncedSwitchCopied, link]) @@ -198,8 +175,9 @@ const SharingLink = ({ nonce, bucketEncryptionKey, refreshNonces }: Props) => { .catch(console.error) .finally(() => { refreshNonces(true) + setTouchedLinksList() }) - }, [filesApiClient, nonce, refreshNonces]) + }, [filesApiClient, nonce.id, refreshNonces, setTouchedLinksList]) return (
    diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/UploadFileModal.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/UploadFileModal.tsx index 3a13b94088..bd91739b78 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/UploadFileModal.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/UploadFileModal.tsx @@ -9,7 +9,6 @@ import { Trans, t } from "@lingui/macro" import clsx from "clsx" import { CSFTheme } from "../../../Themes/types" import { useFileBrowser } from "../../../Contexts/FileBrowserContext" -import { getPathWithFile } from "../../../Utils/pathUtils" const useStyles = makeStyles(({ constants, breakpoints }: CSFTheme) => createStyles({ @@ -81,9 +80,8 @@ interface IUploadFileModuleProps { const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => { const classes = useStyles() const [isDoneDisabled, setIsDoneDisabled] = useState(true) - const { uploadFiles } = useFiles() const { currentPath, refreshContents, bucket } = useFileBrowser() - const { storageSummary } = useFiles() + const { storageSummary, uploadFiles } = useFiles() const UploadSchema = useMemo(() => object().shape({ files: array().required(t`Please select a file to upload`) @@ -105,14 +103,11 @@ const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => { const onSubmit = useCallback(async (values: {files: Array}, helpers) => { if (!bucket) return + helpers.setSubmitting(true) try { close() - const paths = [...new Set(values.files.map(f => f.path.substring(0, f.path.lastIndexOf("/"))))] - paths.forEach(async p => { - const filesToUpload = values.files.filter((f => f.path.substring(0, f.path.lastIndexOf("/")) === p)) - await uploadFiles(bucket, filesToUpload, getPathWithFile(currentPath, p)) - }) + await uploadFiles(bucket, values.files, currentPath) refreshContents && refreshContents() helpers.resetForm() } catch (error: any) { @@ -124,7 +119,7 @@ const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => { const formik = useFormik({ initialValues: { files: [] }, validationSchema: UploadSchema, - onSubmit: onSubmit + onSubmit }) return ( @@ -132,9 +127,7 @@ const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => { active={modalOpen} closePosition="none" maxWidth="sm" - injectedClass={{ - inner: classes.modalInner - }} + injectedClass={{ inner: classes.modalInner }} >
    { return createStyles({ @@ -169,26 +170,7 @@ const FileSystemGridItem = React.forwardRef( const [isEditingLoading, setIsEditingLoading] = useState(false) const { fileName, extension } = useMemo(() => { - if (isFolder) { - return { - fileName : name, - extension: "" - } - } - const split = name.split(".") - const extension = `.${split[split.length - 1]}` - - if (split.length === 1) { - return { - fileName : name, - extension: "" - } - } - - return { - fileName: name.slice(0, name.length - extension.length), - extension: split[split.length - 1] - } + return getFileNameAndExtension(name, isFolder) }, [name, isFolder]) const formik = useFormik({ @@ -240,6 +222,14 @@ const FileSystemGridItem = React.forwardRef( useOnClickOutside(formRef, formik.submitForm) + const renameInputRef = useRef() + + useEffect(() => { + if (editing && renameInputRef?.current) { + renameInputRef.current.focus() + } + }, [editing]) + return (
    { !isFolder && extension !== "" && ( diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx index 1612e8ed54..c90e02d04a 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx @@ -34,6 +34,7 @@ import { useMemo } from "react" import { nameValidator } from "../../../../../Utils/validationSchema" import CustomButton from "../../../../Elements/CustomButton" import { getIconForItem } from "../../../../../Utils/getItemIcon" +import { getFileNameAndExtension } from "../../../../../Utils/Helpers" const useStyles = makeStyles(({ breakpoints, constants }: CSFTheme) => { return createStyles({ @@ -107,6 +108,7 @@ interface IFileSystemItemProps { owners?: BucketUser[] handleSelectItem(selectedItem: FileSystemItemType): void handleAddToSelectedItems(selectedItems: FileSystemItemType): void + handleSelectItemWithShift(selectedItems: FileSystemItemType): void editing: string | undefined setEditing(editing: string | undefined): void handleRename?: (cid: string, newName: string) => Promise | undefined @@ -138,6 +140,7 @@ const FileSystemItem = ({ moveFile, handleSelectItem, handleAddToSelectedItems, + handleSelectItemWithShift, itemOperations, browserView, resetSelectedFiles, @@ -151,30 +154,8 @@ const FileSystemItem = ({ const { cid, name, isFolder } = file const inSharedFolder = useMemo(() => bucket?.type === "share", [bucket]) - const { - fileName, - extension - } = useMemo(() => { - if (isFolder) { - return { - fileName : name, - extension: "" - } - } - const split = name.split(".") - const extension = `.${split[split.length - 1]}` - - if (split.length === 1) { - return { - fileName : name, - extension: "" - } - } - - return { - fileName: name.slice(0, name.length - extension.length), - extension: split[split.length - 1] - } + const { fileName, extension } = useMemo(() => { + return getFileNameAndExtension(name, isFolder) }, [name, isFolder]) const formik = useFormik({ @@ -355,6 +336,7 @@ const FileSystemItem = ({ const [, dragMoveRef, preview] = useDrag({ type: DragTypes.MOVABLE_FILE, + canDrag: !editing, item: () => { if (selectedCids.includes(file.cid)) { return { ids: selectedCids } @@ -381,6 +363,7 @@ const FileSystemItem = ({ canDrop: (item) => isFolder && !item.ids.includes(file.cid), drop: (item: { ids: string[]}) => { moveItems && moveItems(item.ids, getPathWithFile(currentPath, name)) + resetSelectedFiles() }, collect: (monitor) => ({ isOverMove: monitor.isOver() && !monitor.getItem<{ids: string[]}>().ids.includes(file.cid) @@ -401,6 +384,14 @@ const FileSystemItem = ({ const fileOrFolderRef = useRef() + if (fileOrFolderRef?.current) { + if (editing) { + fileOrFolderRef.current.draggable = false + } else { + fileOrFolderRef.current.draggable = true + } + } + if (!editing && desktop) { dragMoveRef(fileOrFolderRef) if (isFolder) { @@ -409,12 +400,25 @@ const FileSystemItem = ({ } } + const handleItemSelectOnCheck = useCallback((e: React.MouseEvent) => { + if (e && (e.ctrlKey || e.metaKey)) { + handleAddToSelectedItems(file) + } else if (e && (e.shiftKey || e.metaKey)) { + handleSelectItemWithShift(file) + } else { + handleAddToSelectedItems(file) + } + }, [handleAddToSelectedItems, handleSelectItemWithShift, file]) + + const onSingleClick = useCallback( (e) => { if (desktop) { // on desktop if (e && (e.ctrlKey || e.metaKey)) { handleAddToSelectedItems(file) + } else if (e && (e.shiftKey || e.metaKey)) { + handleSelectItemWithShift(file) } else { handleSelectItem(file) } @@ -431,7 +435,17 @@ const FileSystemItem = ({ } } }, - [desktop, handleAddToSelectedItems, file, handleSelectItem, isFolder, viewFolder, onFilePreview, selectedCids.length] + [ + desktop, + file, + isFolder, + viewFolder, + onFilePreview, + selectedCids.length, + handleAddToSelectedItems, + handleSelectItemWithShift, + handleSelectItem + ] ) const onDoubleClick = useCallback( @@ -484,6 +498,7 @@ const FileSystemItem = ({ selectedCids, setEditing, resetSelectedFiles, + handleItemSelectOnCheck, longPressEvents: !desktop ? longPressEvents : undefined } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx index 50df6ac46c..8f4d9c47cb 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { makeStyles, createStyles, useThemeSwitcher, useOnClickOutside, LongPressEvents } from "@chainsafe/common-theme" import { t } from "@lingui/macro" import clsx from "clsx" @@ -20,6 +20,7 @@ import { ConnectDragPreview } from "react-dnd" import { Form, FormikProvider, useFormik } from "formik" import { nameValidator } from "../../../../../Utils/validationSchema" import Menu from "../../../../../UI-components/Menu" +import { getFileNameAndExtension } from "../../../../../Utils/Helpers" const useStyles = makeStyles(({ breakpoints, constants, palette }: CSFTheme) => { const desktopGridSettings = "50px 69px 3fr 190px 100px 45px !important" @@ -120,7 +121,7 @@ interface IFileSystemTableItemProps { selectedCids: string[] file: FileSystemItem editing?: string - handleAddToSelectedItems: (selected: FileSystemItem) => void + handleItemSelectOnCheck: (e: React.MouseEvent) => void onFolderOrFileClicks: (e?: React.MouseEvent) => void icon: React.ReactNode preview: ConnectDragPreview @@ -139,7 +140,7 @@ const FileSystemTableItem = React.forwardRef( selectedCids, file, editing, - handleAddToSelectedItems, + handleItemSelectOnCheck, onFolderOrFileClicks, icon, preview, @@ -155,26 +156,7 @@ const FileSystemTableItem = React.forwardRef( const [isEditingLoading, setIsEditingLoading] = useState(false) const { fileName, extension } = useMemo(() => { - if (isFolder) { - return { - fileName : name, - extension: "" - } - } - const split = name.split(".") - const extension = `.${split[split.length - 1]}` - - if (split.length === 1) { - return { - fileName : name, - extension: "" - } - } - - return { - fileName: name.slice(0, name.length - extension.length), - extension: split[split.length - 1] - } + return getFileNameAndExtension(name, isFolder) }, [name, isFolder]) const formik = useFormik({ @@ -203,6 +185,14 @@ const FileSystemTableItem = React.forwardRef( useOnClickOutside(formRef, formik.submitForm) + const renameInputRef = useRef() + + useEffect(() => { + if (editing && renameInputRef?.current) { + renameInputRef.current.focus() + } + }, [editing]) + return ( handleAddToSelectedItems(file)} + onClick={handleItemSelectOnCheck} /> )} @@ -259,7 +249,7 @@ const FileSystemTableItem = React.forwardRef( ? t`Please enter a folder name` : t`Please enter a file name` } - autoFocus + ref={renameInputRef} /> { !isFolder && extension !== "" && ( diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/SharedFolderRow.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/SharedFolderRow.tsx index 52fd715ca4..79049486dc 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/SharedFolderRow.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/SharedFolderRow.tsx @@ -159,13 +159,13 @@ interface Props { const SharedFolderRow = ({ bucket, handleRename, openSharedFolder, handleDeleteSharedFolder, onEditSharedFolder }: Props) => { const classes = useStyles() const { name, size } = bucket - const { desktop } = useThemeSwitcher() const [isRenaming, setIsRenaming] = useState(false) const formRef = useRef(null) const isOwner = useMemo(() => bucket.permission === "owner", [bucket.permission]) const [ownerName, setOwnerName] = useState("") const [isEditingLoading, setIsEditingLoading] = useState(false) + const renameInputRef = useRef() useEffect(() => { if (isOwner) { @@ -178,6 +178,12 @@ const SharedFolderRow = ({ bucket, handleRename, openSharedFolder, handleDeleteS .catch(console.error) }, [bucket, isOwner]) + useEffect(() => { + if (isRenaming && renameInputRef?.current) { + renameInputRef.current.focus() + } + }, [isRenaming]) + const menuItems: IMenuItem[] = isOwner ? [{ contents: ( @@ -327,7 +333,8 @@ const SharedFolderRow = ({ bucket, handleRename, openSharedFolder, handleDeleteS } }} placeholder = {t`Please enter a folder name`} - autoFocus={isRenaming} + autoFocus + ref={renameInputRef} /> diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesList.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesList.tsx index f7366a18c4..f4ececb0f7 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesList.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesList.tsx @@ -129,7 +129,8 @@ const useStyles = makeStyles( backgroundColor: palette.additional["gray"][4] }, [breakpoints.up("md")]: { - margin: `${constants.generalUnit * 3}px 0` + marginTop: constants.generalUnit * 3, + marginBottom: 0 }, [breakpoints.down("md")]: { margin: `${constants.generalUnit * 3}px 0 0` @@ -255,11 +256,19 @@ const useStyles = makeStyles( bulkOperations: { display: "flex", flexDirection: "row", - marginTop: constants.generalUnit * 3, - marginBottom: constants.generalUnit * 3, - minHeight: constants.generalUnit * 4.2, // reserve space for buttons for the interface not to jump when they get visible + position: "sticky", + top: "80px", + backgroundColor: palette.additional["gray"][1], + zIndex: zIndex?.layer0, + minHeight: constants.generalUnit * 5 + 34, + alignItems: "center", "& > *": { marginRight: constants.generalUnit + }, + [breakpoints.up("md")]: { + // prevent grid shadows overflow showing + marginLeft: "-4px", + paddingLeft: "4px" } }, confirmDeletionDialog: { @@ -271,7 +280,6 @@ const useStyles = makeStyles( gridColumnGap: constants.generalUnit * 2, gridRowGap: constants.generalUnit * 2, marginBottom: constants.generalUnit * 4, - marginTop: constants.generalUnit * 4, [breakpoints.down("lg")]: { gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)" }, @@ -319,6 +327,14 @@ const useStyles = makeStyles( }, checkIcon: { fill: palette.additional.gray[8] + }, + tableHead: { + position: "sticky", + top: constants.generalUnit * 5 + 34 + 80, + zIndex: zIndex?.layer0, + [breakpoints.down("md")]: { + top: 50 + } } }) } @@ -467,13 +483,9 @@ const FilesList = ({ isShared = false }: Props) => { // Selection logic const handleSelectItem = useCallback( (item: FileSystemItemType) => { - if (selectedCids.includes(item.cid)) { - setSelectedItems([]) - } else { - setSelectedItems([item]) - } + setSelectedItems([item]) }, - [selectedCids] + [] ) const handleAddToSelectedItems = useCallback( @@ -489,6 +501,46 @@ const FilesList = ({ isShared = false }: Props) => { [selectedCids, selectedItems] ) + // select item with SHIFT pressed + const handleSelectItemWithShift = useCallback( + (item: FileSystemItemType) => { + // item already selected + const isItemAlreadySelected = selectedItems.includes(item) + if (isItemAlreadySelected) return + + const lastIndex = selectedItems.length + ? items.findIndex((i) => i.cid === selectedItems[selectedItems.length - 1].cid) + : -1 + + // first item + if (lastIndex === -1) { + setSelectedItems([item]) + return + } + + const currentIndex = items.findIndex((i) => i.cid === item.cid) + // unavailable item + if (currentIndex === -1) return + + // new item, with selected items + let countIndex = lastIndex + let mySelectedItems = selectedItems + while ( + (currentIndex > lastIndex && countIndex <= currentIndex) || + (currentIndex < lastIndex && countIndex >= currentIndex) + ) { + // filter out if item already selected + const currentCID = items[countIndex].cid + mySelectedItems = mySelectedItems.filter((s) => s.cid !== currentCID) + mySelectedItems.push(items[countIndex]) + if (currentIndex > lastIndex) countIndex++ + else countIndex-- + } + setSelectedItems([...mySelectedItems]) + }, + [selectedItems, items] + ) + const toggleAll = useCallback(() => { if (selectedItems.length === items.length) { setSelectedItems([]) @@ -853,7 +905,8 @@ const FilesList = ({ isShared = false }: Props) => { homeOnClick={() => redirect(moduleRootPath)} homeRef={homeBreadcrumbRef} homeActive={isOverUploadHomeBreadcrumb || isOverMoveHomeBreadcrumb} - showDropDown={!desktop} + showDropDown + maximumCrumbs={desktop ? 5 : 3} /> )}
    @@ -1053,7 +1106,7 @@ const FilesList = ({ isShared = false }: Props) => { testId="home" > {desktop ? ( - + { ) : ( - + { selectedCids={selectedCids} handleSelectItem={handleSelectItem} handleAddToSelectedItems={handleAddToSelectedItems} + handleSelectItemWithShift={handleSelectItemWithShift} editing={editing} setEditing={setEditing} handleRename={(cid: string, newName: string) => { @@ -1255,6 +1309,7 @@ const FilesList = ({ isShared = false }: Props) => { handleSelectItem={handleSelectItem} viewFolder={handleViewFolder} handleAddToSelectedItems={handleAddToSelectedItems} + handleSelectItemWithShift={handleSelectItemWithShift} editing={editing} setEditing={setEditing} handleRename={(path: string, newPath: string) => { diff --git a/packages/files-ui/src/Components/Modules/LoginModule/InitialScreen.tsx b/packages/files-ui/src/Components/Modules/LoginModule/InitialScreen.tsx index 90e8744fc5..7761786323 100644 --- a/packages/files-ui/src/Components/Modules/LoginModule/InitialScreen.tsx +++ b/packages/files-ui/src/Components/Modules/LoginModule/InitialScreen.tsx @@ -537,6 +537,7 @@ const InitialScreen = ({ className }: IInitialScreen) => { href={ROUTE_LINKS.PrivacyPolicy} target="_blank" rel="noopener noreferrer" + data-cy="link-privacy-policy" > Privacy Policy @@ -546,6 +547,7 @@ const InitialScreen = ({ className }: IInitialScreen) => { href={ROUTE_LINKS.Terms} target="_blank" rel="noopener noreferrer" + data-cy="link-terms-and-conditions" > Terms and Conditions diff --git a/packages/files-ui/src/Components/Modules/Settings/index.tsx b/packages/files-ui/src/Components/Modules/Settings/index.tsx index 595152d8a4..f78c2438d4 100644 --- a/packages/files-ui/src/Components/Modules/Settings/index.tsx +++ b/packages/files-ui/src/Components/Modules/Settings/index.tsx @@ -27,7 +27,7 @@ const TabPane = (props: ITabPaneProps) => TabPaneOrigin(props) const useStyles = makeStyles(({ constants, breakpoints, palette }: ITheme) => createStyles({ title: { - marginTop: constants.generalUnit, + marginTop: constants.generalUnit * 4, [breakpoints.down("md")]: { fontSize: 18, lineHeight: "22px", diff --git a/packages/files-ui/src/Components/Pages/LoginPage.tsx b/packages/files-ui/src/Components/Pages/LoginPage.tsx index 7ac2c5b9b5..095ef8a9de 100644 --- a/packages/files-ui/src/Components/Pages/LoginPage.tsx +++ b/packages/files-ui/src/Components/Pages/LoginPage.tsx @@ -182,6 +182,7 @@ const LoginPage = () => { href={ROUTE_LINKS.ChainSafe} target="_blank" rel="noopener noreferrer" + data-cy="link-learn-more-about-chainsafe" > diff --git a/packages/files-ui/src/Contexts/FilesContext.tsx b/packages/files-ui/src/Contexts/FilesContext.tsx index b76d65c1dc..58f6dbb9ba 100644 --- a/packages/files-ui/src/Contexts/FilesContext.tsx +++ b/packages/files-ui/src/Contexts/FilesContext.tsx @@ -20,8 +20,9 @@ import { useBeforeunload } from "react-beforeunload" import { useThresholdKey } from "./ThresholdKeyContext" import { useFilesApi } from "./FilesApiContext" import { useUser } from "./UserContext" -import { getPathWithFile, getRelativePath } from "../Utils/pathUtils" +import { getParentPathFromFilePath, getPathWithFile, getRelativePath } from "../Utils/pathUtils" import { Zippable, zipSync } from "fflate" +import { FileWithPath } from "../Utils/getFilesFromDataTransferItems" type FilesContextProps = { children: React.ReactNode | React.ReactNode[] @@ -52,7 +53,7 @@ type FilesContext = { storageSummary: BucketSummaryResponse | undefined personalEncryptionKey: string | undefined getStorageSummary: () => Promise - uploadFiles: (bucket: BucketKeyPermission, files: File[], path: string, encryptionKey?: string) => Promise + uploadFiles: (bucket: BucketKeyPermission, files: FileWithPath[], rootUploadPath: string) => Promise downloadFile: (bucketId: string, itemToDownload: FileSystemItem, path: string) => void getFileContent: (bucketId: string, params: GetFileContentParams) => Promise refreshBuckets: (showLoading?: boolean) => Promise @@ -319,7 +320,6 @@ const FilesProvider = ({ children }: FilesContextProps) => { onUploadProgress?: (progressEvent: ProgressEvent) => void, cancelToken?: CancelToken ) => { - const key = bucket.encryptionKey if (!key) { @@ -350,7 +350,7 @@ const FilesProvider = ({ children }: FilesContextProps) => { ) }, [filesApiClient]) - const uploadFiles = useCallback(async (bucket: BucketKeyPermission, files: File[], path: string) => { + const uploadFiles = useCallback(async (bucket: BucketKeyPermission, files: FileWithPath[], rootUploadPath: string) => { const hasOversizedFile = files.some(file => file.size > MAX_FILE_SIZE) if (hasOversizedFile) { addToast({ @@ -377,20 +377,31 @@ const FilesProvider = ({ children }: FilesContextProps) => { setUploadsInProgress(true) try { - await encryptAndUploadFiles( - bucket, - files, - path, - (progressEvent: { loaded: number; total: number }) => { - updateToast(toastId, { - ...toastParams, - progress: Math.ceil( - (progressEvent.loaded / progressEvent.total) * 100 - ) - }) - }, - cancelToken - ) + const paths = [...new Set(files.map(f => getParentPathFromFilePath(f.path)))] + const totalUploadSize = files.reduce((sum, f) => sum += f.size, 0) + + let uploadedSize = 0 + for (const path of paths) { + const filesToUpload = files.filter((f => getParentPathFromFilePath(f.path) === path)) + const batchSize = filesToUpload.reduce((sum, f) => sum += f.size, 0) + await encryptAndUploadFiles( + bucket, + filesToUpload, + getPathWithFile(rootUploadPath, path), + (progressEvent: { loaded: number; total: number }) => { + updateToast(toastId, { + ...toastParams, + progress: Math.ceil( + ((progressEvent.loaded + uploadedSize) / totalUploadSize) * 100 + ) + }) + }, + cancelToken + ) + uploadedSize += batchSize + } + + setUploadsInProgress(false) await refreshBuckets() diff --git a/packages/files-ui/src/Contexts/NotificationsContext.tsx b/packages/files-ui/src/Contexts/NotificationsContext.tsx index a5daa2354b..dd3b9ea73c 100644 --- a/packages/files-ui/src/Contexts/NotificationsContext.tsx +++ b/packages/files-ui/src/Contexts/NotificationsContext.tsx @@ -12,9 +12,7 @@ interface INotificationsContext { removeNotification: (id: string) => void } -const NotificationsContext = React.createContext( - undefined -) +const NotificationsContext = React.createContext(undefined) const NotificationsProvider = ({ children }: NotificationsContextProps) => { const [notifications, setNotifications] = useState([]) diff --git a/packages/files-ui/src/Themes/Constants.ts b/packages/files-ui/src/Themes/Constants.ts index 1396e76477..69769adc9b 100644 --- a/packages/files-ui/src/Themes/Constants.ts +++ b/packages/files-ui/src/Themes/Constants.ts @@ -4,8 +4,8 @@ export const UI_CONSTANTS = { mobileButtonHeight: 44, headerHeight: 60, navWidth: 8 * 27, - contentPadding: 8 * 15, - contentTopPadding: 8 * 15, + contentPadding: 6 * 15, + contentTopPadding: 6 * 15, mobileHeaderHeight: 8 * 6.3, svgWidth: 8 * 2.5, topPadding: 8 * 3, diff --git a/packages/files-ui/src/Themes/DarkTheme.ts b/packages/files-ui/src/Themes/DarkTheme.ts index d4bec80484..f792eb6096 100644 --- a/packages/files-ui/src/Themes/DarkTheme.ts +++ b/packages/files-ui/src/Themes/DarkTheme.ts @@ -436,7 +436,7 @@ export const darkTheme = createTheme({ filesTable: { color: "var(--gray7)", uploadText: "var(--gray7)", - gridItemShadow: "0px 4px 4px rgba(0, 0, 0, 0.25)" + gridItemShadow: "1px 1px 4px rgba(0, 0, 0, 0.25)" }, fileSystemItemRow: { icon: "var(--gray9)", diff --git a/packages/files-ui/src/Themes/LightTheme.ts b/packages/files-ui/src/Themes/LightTheme.ts index 367c2e71ca..2a47c5cb5b 100644 --- a/packages/files-ui/src/Themes/LightTheme.ts +++ b/packages/files-ui/src/Themes/LightTheme.ts @@ -126,7 +126,7 @@ export const lightTheme = createTheme({ filesTable: { color: "", uploadText: "var(--gray2)", - gridItemShadow: "0px 4px 4px rgba(0, 0, 0, 0.25)" + gridItemShadow: "1px 1px 4px rgba(0, 0, 0, 0.25)" }, fileSystemItemRow: { icon: "var(--gray8)", diff --git a/packages/files-ui/src/Utils/Helpers.tsx b/packages/files-ui/src/Utils/Helpers.tsx index 943e0a9905..356719b7be 100644 --- a/packages/files-ui/src/Utils/Helpers.tsx +++ b/packages/files-ui/src/Utils/Helpers.tsx @@ -44,3 +44,26 @@ export const parseFileContentResponse = (fcr: FileContentResponse): FileSystemIt isFolder: fcr.content_type === "application/chainsafe-files-directory" }) + +export const getFileNameAndExtension = (name: string, isFolder: boolean) => { + if (isFolder) { + return { + fileName : name, + extension: "" + } + } + const split = name.split(".") + const extension = `.${split[split.length - 1]}` + + if (split.length === 1) { + return { + fileName : name, + extension: "" + } + } + + return { + fileName: name.slice(0, name.length - extension.length), + extension: split[split.length - 1] + } +} diff --git a/packages/files-ui/src/Utils/getFilesFromDataTransferItems.ts b/packages/files-ui/src/Utils/getFilesFromDataTransferItems.ts index 6b21bb1d21..4fc5b0d153 100644 --- a/packages/files-ui/src/Utils/getFilesFromDataTransferItems.ts +++ b/packages/files-ui/src/Utils/getFilesFromDataTransferItems.ts @@ -1,12 +1,14 @@ //Shamelessly borrowed from https://github.com/anatol-grabowski/datatransfer-files-promise with added Types -type FileWithPath = File & {filepath: string} +export type FileWithPath = File & { + path: string +} const getFilesFromDataTransferItems = async (dataTransferItems: DataTransferItemList): Promise> => { const readFile = (entry: FileEntry, path = ""): Promise => { return new Promise((resolve, reject) => { entry.file((file: File) => { - Object.defineProperty(file, "filepath", { + Object.defineProperty(file, "path", { value: path }) resolve(file as FileWithPath) diff --git a/packages/files-ui/src/Utils/pathUtils.ts b/packages/files-ui/src/Utils/pathUtils.ts index 72fbaf65b2..175c0ba479 100644 --- a/packages/files-ui/src/Utils/pathUtils.ts +++ b/packages/files-ui/src/Utils/pathUtils.ts @@ -43,11 +43,18 @@ export function getURISafePathFromArray(arrayOfPaths: string[]): string { // /path/to/somewhere + 1.txt -> /path/to/somewhere/1.txt // /path/to/somewhere/ + 1.txt -> /path/to/somewhere/1.txt export function getPathWithFile(path: string, fileName: string) { + + // make sure the file name doesn't start with a / + // otherwise remove it + const nameToUse = fileName.startsWith("/") + ? fileName.substring(1) + : fileName + return path === "/" - ? `/${fileName}` + ? `/${nameToUse}` : path[path.length - 1] === "/" - ? `${path}${fileName}` - : `${path}/${fileName}` + ? `${path}${nameToUse}` + : `${path}/${nameToUse}` } // Removes a parent element diff --git a/packages/files-ui/src/locales/fr/messages.po b/packages/files-ui/src/locales/fr/messages.po index 4aeff95518..425c01f3b4 100644 --- a/packages/files-ui/src/locales/fr/messages.po +++ b/packages/files-ui/src/locales/fr/messages.po @@ -1107,7 +1107,7 @@ msgid "We are performing routine maintenance of the system. Service status updat msgstr "Nous effectuons une maintenance de routine du systĆØme. Les mises Ć  jour de l'Ć©tat du service seront postĆ©es sur le canal <0>Files Support<0> sur Discord" msgid "We can't encrypt files larger than 2GB. Some items will not be uploaded" -msgstr "Nous ne pouvons pas chiffrer les fichiers de plus de 2Ā Go. Certains Ć©lĆ©ments ne pourront pas ĆŖtre tĆ©lĆ©versĆ©s" +msgstr "Nous ne pouvons pas chiffrer les fichiers de plus de 2 Go. Certains Ć©lĆ©ments ne pourront pas ĆŖtre tĆ©lĆ©versĆ©s" msgid "Web3: {0}" msgstr "Web3 : {0}" diff --git a/packages/storage-ui/.env.example b/packages/storage-ui/.env.example index 5f61e2ed06..f8f5315c29 100644 --- a/packages/storage-ui/.env.example +++ b/packages/storage-ui/.env.example @@ -7,7 +7,6 @@ REACT_APP_IPFS_GATEWAY=https://ipfs.chainsafe.io/ipfs REACT_APP_STRIPE_PK= REACT_APP_SENTRY_DSN_URL= REACT_APP_SENTRY_ENV=development -REACT_APP_HOTJAR_ID= # Get your ID on Blocknative: https://explorer.blocknative.com/account REACT_APP_BLOCKNATIVE_ID= REACT_APP_MAINTENANCE_MODE=false diff --git a/packages/storage-ui/cypress.json b/packages/storage-ui/cypress.json index be2883e932..4ecd52a5e5 100644 --- a/packages/storage-ui/cypress.json +++ b/packages/storage-ui/cypress.json @@ -5,5 +5,6 @@ "retries": { "runMode": 2, "openMode": 0 - } + }, + "chromeWebSecurity": false } diff --git a/packages/storage-ui/cypress/fixtures/storageTestData.ts b/packages/storage-ui/cypress/fixtures/storageTestData.ts index 5deaffcd91..29f0ff0b0e 100644 --- a/packages/storage-ui/cypress/fixtures/storageTestData.ts +++ b/packages/storage-ui/cypress/fixtures/storageTestData.ts @@ -1,3 +1,5 @@ -export const bucketName = "test bucket" +export const chainSafeBucketName = `cs bucket ${Date.now()}` +export const ipfsBucketName = `ipfs bucket ${Date.now()}` export const testCid = "QmZEE7Ymh2mRMURLnFLipJTovb44AoUYDzx7aipYZwvxX5" -export const testCidName = "cute cat" +export const testCidAlternative = "QmSMNExVSNKwWNeXL3o6a2og13441g1eUqLgTeuCSDvN2W" +export const testCidName = "cute cat" \ No newline at end of file diff --git a/packages/storage-ui/cypress/support/index.ts b/packages/storage-ui/cypress/support/index.ts index 19366f75fe..73e1aed7e9 100644 --- a/packages/storage-ui/cypress/support/index.ts +++ b/packages/storage-ui/cypress/support/index.ts @@ -34,3 +34,16 @@ if(app != null && !app.document.head.querySelector("[data-hide-command-log-reque app.document.head.appendChild(style) } + +before(() => { + // grant clipboard read permissions to the browser + cy.wrap( + Cypress.automation("remote:debugger:protocol", { + command: "Browser.grantPermissions", + params: { + permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], + origin: window.location.origin, + } + }) + ) +}) diff --git a/packages/storage-ui/cypress/support/page-objects/authenticationPage.ts b/packages/storage-ui/cypress/support/page-objects/authenticationPage.ts index 4abe55be45..510f8e4caf 100644 --- a/packages/storage-ui/cypress/support/page-objects/authenticationPage.ts +++ b/packages/storage-ui/cypress/support/page-objects/authenticationPage.ts @@ -7,5 +7,8 @@ export const authenticationPage = { web3Button: () => cy.get("[data-cy=button-web3]", { timeout: 120000 }), showMoreButton: () => cy.get("div.svelte-q1527 > .bn-onboard-custom"), detectedWallet: () => cy.get(":nth-child(3) > .bn-onboard-custom > span.svelte-1799bj2"), - web3SignInButton: () => cy.get("[data-cy=button-sign-in-with-web3]", { timeout: 10000 }) + web3SignInButton: () => cy.get("[data-cy=button-sign-in-with-web3]", { timeout: 10000 }), + privacyPolicyLink: () => cy.get("[data-cy=link-privacy-policy]"), + termsAndConditionsLink: () => cy.get("[data-cy=link-terms-and-conditions]"), + learnMoreAboutChainsafeLink: () => cy.get("[data-cy=link-learn-more-about-chainsafe]"), } diff --git a/packages/storage-ui/cypress/support/page-objects/bucketContentsPage.ts b/packages/storage-ui/cypress/support/page-objects/bucketContentsPage.ts new file mode 100644 index 0000000000..474c97d88e --- /dev/null +++ b/packages/storage-ui/cypress/support/page-objects/bucketContentsPage.ts @@ -0,0 +1,27 @@ +import { basePage } from "./basePage" + +export const bucketContentsPage = { + ...basePage, + + // bucket content browser elements + bucketHeaderLabel: () => cy.get("[data-cy=header-bucket]"), + newFolderButton: () => cy.get("[data-testid=button-new-folder] "), + uploadButton: () => cy.get("[data-testid=button-upload-file]"), + + // file or folder browser row elements + fileItemKebabButton: () => cy.get("[data-testid=icon-file-item-kebab]"), + fileItemName: () => cy.get("[data-cy=label-file-item-name]"), + fileItemRow: () => cy.get("[data-cy=row-file-item]"), + + // kebab menu elements + downloadMenuOption: () => cy.get("[data-cy=menu-download]"), + renameMenuOption: () => cy.get("[data-cy=menu-rename]"), + moveMenuOption: () => cy.get("[data-cy=menu-move]"), + deleteMenuOption: () => cy.get("[data-cy=menu-delete]"), + + // helpers and convenience functions + awaitBucketRefresh() { + cy.intercept("POST", "**/bucket/*/ls").as("refresh") + cy.wait("@refresh") + } +} diff --git a/packages/storage-ui/cypress/support/page-objects/bucketsPage.ts b/packages/storage-ui/cypress/support/page-objects/bucketsPage.ts index 650a44b123..472f221c58 100644 --- a/packages/storage-ui/cypress/support/page-objects/bucketsPage.ts +++ b/packages/storage-ui/cypress/support/page-objects/bucketsPage.ts @@ -1,6 +1,7 @@ // Only add things here that could be applicable to the bucket page import { basePage } from "./basePage" +import { createBucketModal } from "./modals/createBucketModal" export const bucketsPage = { ...basePage, @@ -10,17 +11,12 @@ export const bucketsPage = { createBucketButton: () => cy.get("[data-cy=button-create-bucket]"), // bucket browser row elements - bucketItemRow: () => cy.get("[data-cy=row-bucket-item]", { timeout: 20000 }), + bucketItemRow: () => cy.get("[data-cy=row-bucket-item]"), nameTableHeader: () => cy.get("[data-cy=table-header-name]"), sizeTableHeader: () => cy.get("[data-cy=table-header-size]"), bucketItemName: () => cy.get("[data-cy=cell-bucket-name]"), - bucketRowKebabButton: () => cy.get("[data-testid=dropdown-title-bucket-kebab]", { timeout: 10000 }), - - // create bucket modal elements - createBucketForm: () => cy.get("[data-testid=form-create-bucket]", { timeout: 10000 }), - bucketNameInput: () => cy.get("[data-cy=input-bucket-name]", { timeout: 10000 }), - createBucketCancelButton: () => cy.get("[data-cy=button-cancel-create]"), - createBucketSubmitButton: () => cy.get("[data-cy=button-submit-create]", { timeout: 10000 }), + bucketFileSystemType: () => cy.get("[data-cy=cell-file-system-type]"), + bucketRowKebabButton: () => cy.get("[data-testid=dropdown-title-bucket-kebab]"), // menu elements deleteBucketMenuOption: () => cy.get("[data-cy=menu-delete-bucket]"), @@ -28,8 +24,9 @@ export const bucketsPage = { // helpers and convenience functions createBucket(bucketName: string) { this.createBucketButton().click() - this.bucketNameInput().type(bucketName) - this.createBucketSubmitButton().safeClick() - this.createBucketForm().should("not.exist") + createBucketModal.body().should("be.visible") + createBucketModal.bucketNameInput().type(bucketName) + createBucketModal.submitButton().safeClick() + createBucketModal.body().should("not.exist") } -} \ No newline at end of file +} diff --git a/packages/storage-ui/cypress/support/page-objects/cidsPage.ts b/packages/storage-ui/cypress/support/page-objects/cidsPage.ts index 3a19c53a15..3d17b8143b 100644 --- a/packages/storage-ui/cypress/support/page-objects/cidsPage.ts +++ b/packages/storage-ui/cypress/support/page-objects/cidsPage.ts @@ -1,7 +1,7 @@ // Only add things here that could be applicable to the cids page import { basePage } from "./basePage" -import { testCid } from "../../fixtures/storageTestData" +import { testCid, testCidName } from "../../fixtures/storageTestData" import { addCidModal } from "./modals/addCidModal" export const cidsPage = { @@ -12,21 +12,27 @@ export const cidsPage = { pinButton: () => cy.get("[data-cy=button-pin-cid]"), // cid browser row elements + searchCidInput: () => cy.get("[data-testid=input-search-cid]"), cidTableHeader: () => cy.get("[data-cy=table-header-cid]"), createdTableHeader: () => cy.get("[data-cy=table-header-created]"), sizeTableHeader: () => cy.get("[data-cy=table-header-size]"), statusTableHeader: () => cy.get("[data-cy=table-header-status]"), cidItemRow: () => cy.get("[data-cy=row-cid-item]", { timeout: 20000 }), cidNameCell: () => cy.get("[data-cy=cell-pin-name]"), + cidCell: () => cy.get("[data-cy=cell-pin-cid]"), cidRowKebabButton: () => cy.get("[data-testid=dropdown-title-cid-kebab]"), // menu elements unpinMenuOption: () => cy.get("[data-cy=menu-unpin]"), // helpers and convenience functions - addPinnedCid() { + addPinnedCid(cidDetails?: {name?: string; cid?: string}) { + const name = cidDetails?.name ? cidDetails.name : testCidName + const cid = cidDetails?.cid ? cidDetails.cid : testCid + this.pinButton().click() - addCidModal.cidInput().type(testCid) + addCidModal.nameInput().type(name) + addCidModal.cidInput().type(cid) addCidModal.pinSubmitButton().click() this.cidItemRow().should("have.length", 1) } diff --git a/packages/storage-ui/cypress/support/page-objects/modals/createBucketModal.ts b/packages/storage-ui/cypress/support/page-objects/modals/createBucketModal.ts new file mode 100644 index 0000000000..48dd45ed21 --- /dev/null +++ b/packages/storage-ui/cypress/support/page-objects/modals/createBucketModal.ts @@ -0,0 +1,8 @@ +export const createBucketModal = { + body: () => cy.get("[data-testid=modal-container-create-bucket]"), + bucketNameInput: () => cy.get("[data-cy=input-bucket-name]"), + chainsafeRadioInput: () => cy.get("[data-testid=radio-input-chainsafe]"), + ipfsRadioInput: () => cy.get("[data-testid=radio-input-ipfs]"), + cancelButton: () => cy.get("[data-cy=button-cancel-create]"), + submitButton: () => cy.get("[data-cy=button-submit-create]") +} diff --git a/packages/storage-ui/cypress/support/page-objects/modals/fileUploadModal.ts b/packages/storage-ui/cypress/support/page-objects/modals/fileUploadModal.ts new file mode 100644 index 0000000000..70b88ff6d0 --- /dev/null +++ b/packages/storage-ui/cypress/support/page-objects/modals/fileUploadModal.ts @@ -0,0 +1,9 @@ +export const fileUploadModal = { + body: () => cy.get("[data-cy=form-upload-file] input"), + cancelButton: () => cy.get("[data-testid=button-cancel-upload]"), + fileList: () => cy.get("[data-testid=list-fileUpload] li"), + removeFileButton: () => cy.get("[data-testid=button-remove-from-file-list]"), + uploadButton: () => cy.get("[data-testid=button-start-upload]"), + uploadDropzone : () => cy.get("[data-testid=input-file-dropzone-fileUpload]"), + errorLabel: () => cy.get("[data-testid=meta-error-message-fileUpload]") +} diff --git a/packages/storage-ui/cypress/support/page-objects/navigationMenu.ts b/packages/storage-ui/cypress/support/page-objects/navigationMenu.ts index 058031ac53..266ce5ae02 100644 --- a/packages/storage-ui/cypress/support/page-objects/navigationMenu.ts +++ b/packages/storage-ui/cypress/support/page-objects/navigationMenu.ts @@ -9,5 +9,5 @@ export const navigationMenu = { spaceUsedLabel: () => cy.get("[data-cy=label-space-used]"), spaceUsedProgressBar: () => cy.get("[data-cy=progress-bar-space-used]"), // mobile view only - signOutButton: () => cy.get("[data-cy=container-signout-nav]") + signOutButton: () => cy.get("[data-cy=button-sign-out]") } diff --git a/packages/storage-ui/cypress/support/page-objects/toasts/uploadStatusToast.ts b/packages/storage-ui/cypress/support/page-objects/toasts/uploadStatusToast.ts new file mode 100644 index 0000000000..d9bf2b2d87 --- /dev/null +++ b/packages/storage-ui/cypress/support/page-objects/toasts/uploadStatusToast.ts @@ -0,0 +1,3 @@ +export const uploadStatusToast = { + body: () => cy.get("[data-cy=upload_status_toast_message]", { timeout: 20000 }) +} diff --git a/packages/storage-ui/cypress/tests/bucket-management-spec.ts b/packages/storage-ui/cypress/tests/bucket-management-spec.ts index e72fa7ad98..92b5a44062 100644 --- a/packages/storage-ui/cypress/tests/bucket-management-spec.ts +++ b/packages/storage-ui/cypress/tests/bucket-management-spec.ts @@ -1,38 +1,96 @@ import { bucketsPage } from "../support/page-objects/bucketsPage" -import { bucketName } from "../fixtures/storageTestData" - +import { bucketContentsPage } from "../support/page-objects/bucketContentsPage" +import { chainSafeBucketName, ipfsBucketName } from "../fixtures/storageTestData" +import { createBucketModal } from "../support/page-objects/modals/createBucketModal" import { navigationMenu } from "../support/page-objects/navigationMenu" +import { fileUploadModal } from "../support/page-objects/modals/fileUploadModal" +import { uploadStatusToast } from "../support/page-objects/toasts/uploadStatusToast" describe("Bucket management", () => { context("desktop", () => { - it("can create a bucket", () => { + it("can create, upload file and delete a chainsafe bucket", () => { cy.web3Login({ clearPins: true, deleteFpsBuckets: true }) + navigationMenu.bucketsNavButton().click() + + // open create bucket modal and cancel it + bucketsPage.createBucketButton().click() + createBucketModal.cancelButton().click() + createBucketModal.body().should("not.exist") // create a bucket and see it in the bucket table - navigationMenu.bucketsNavButton().click() bucketsPage.createBucketButton().click() - bucketsPage.bucketNameInput().type(bucketName) - bucketsPage.createBucketSubmitButton().safeClick() + createBucketModal.body().should("be.visible") + createBucketModal.bucketNameInput().type(chainSafeBucketName) + createBucketModal.chainsafeRadioInput().click() + createBucketModal.submitButton().click() bucketsPage.bucketItemRow().should("have.length", 1) - bucketsPage.bucketItemName().should("have.text", bucketName) + bucketsPage.bucketItemName().should("have.text", chainSafeBucketName) + bucketsPage.bucketFileSystemType().should("have.text", "Chainsafe") - // open create bucket modal and cancel it - bucketsPage.createBucketButton().click() - bucketsPage.createBucketCancelButton().click() - bucketsPage.createBucketForm().should("not.exist") + // open bucket and ensure header matches the expected value + bucketsPage.bucketItemName().dblclick() + bucketContentsPage.bucketHeaderLabel() + .should("be.visible") + .should("contain.text", chainSafeBucketName) + + // upload a file to the bucket + bucketContentsPage.uploadButton().click() + fileUploadModal.body().attachFile("../fixtures/uploadedFiles/logo.png") + fileUploadModal.fileList().should("have.length", 1) + fileUploadModal.uploadButton().safeClick() + fileUploadModal.body().should("not.exist") + uploadStatusToast.body().should("be.visible") + bucketContentsPage.awaitBucketRefresh() + bucketContentsPage.fileItemRow().should("have.length", 1) + + // delete chainsafe bucket + navigationMenu.bucketsNavButton().click() + bucketsPage.bucketRowKebabButton() + .should("be.visible") + .click() + bucketsPage.deleteBucketMenuOption().click() + bucketsPage.bucketItemRow().should("not.exist") + bucketsPage.bucketItemName().should("not.exist") }) - it("can delete a bucket", () => { + it("can create, upload file and delete an ipfs bucket", () => { cy.web3Login({ clearPins: true, deleteFpsBuckets: true }) + navigationMenu.bucketsNavButton().click() + + // create a bucket and see it in the bucket table + bucketsPage.createBucketButton().click() + createBucketModal.body().should("be.visible") + createBucketModal.bucketNameInput().type(ipfsBucketName) + createBucketModal.ipfsRadioInput().click() + createBucketModal.submitButton().click() + bucketsPage.bucketItemRow().should("have.length", 1) + bucketsPage.bucketItemName().should("have.text", ipfsBucketName) + bucketsPage.bucketFileSystemType().should("have.text", "IPFS MFS") + + // open bucket and ensure header matches the expected value + bucketsPage.bucketItemName().dblclick() + bucketContentsPage.bucketHeaderLabel() + .should("be.visible") + .should("contain.text", ipfsBucketName) + + // upload a file to the bucket + bucketContentsPage.uploadButton().click() + fileUploadModal.body().attachFile("../fixtures/uploadedFiles/logo.png") + fileUploadModal.fileList().should("have.length", 1) + fileUploadModal.uploadButton().safeClick() + fileUploadModal.body().should("not.exist") + uploadStatusToast.body().should("be.visible") + bucketContentsPage.awaitBucketRefresh() + bucketContentsPage.fileItemRow().should("have.length", 1) - // delete a bucket and ensure its row is removed + // delete ipfs bucket navigationMenu.bucketsNavButton().click() - // creating a bucket with a unique name - bucketsPage.createBucket(`${bucketName}_${Date.now()}`) - bucketsPage.bucketRowKebabButton().first().click() - bucketsPage.deleteBucketMenuOption().first().click() + bucketsPage.bucketRowKebabButton() + .should("be.visible") + .click() + bucketsPage.deleteBucketMenuOption().click() bucketsPage.bucketItemRow().should("not.exist") bucketsPage.bucketItemName().should("not.exist") }) diff --git a/packages/storage-ui/cypress/tests/cid-management-spec.ts b/packages/storage-ui/cypress/tests/cid-management-spec.ts index 56452c2c04..794f36c0cf 100644 --- a/packages/storage-ui/cypress/tests/cid-management-spec.ts +++ b/packages/storage-ui/cypress/tests/cid-management-spec.ts @@ -1,6 +1,6 @@ import { cidsPage } from "../support/page-objects/cidsPage" import { navigationMenu } from "../support/page-objects/navigationMenu" -import { testCid, testCidName } from "../fixtures/storageTestData" +import { testCid, testCidAlternative, testCidName } from "../fixtures/storageTestData" import { addCidModal } from "../support/page-objects/modals/addCidModal" describe("CID management", () => { @@ -41,19 +41,67 @@ describe("CID management", () => { cidsPage.unpinMenuOption().click() cidsPage.cidItemRow().should("contain.text", "queued") }) - }) - it("can see a warning when attempting to pin the same CID twice", () => { - cy.web3Login({ withNewSession: true }) - navigationMenu.cidsNavButton().click() + it("can see a warning when attempting to pin the same CID twice", () => { + cy.web3Login({ withNewSession: true }) + navigationMenu.cidsNavButton().click() + + // add a cid + cidsPage.addPinnedCid() + + // see warning if attempting to pin the cid again + cidsPage.pinButton().click() + addCidModal.body().should("be.visible") + addCidModal.cidInput().type(testCid) + addCidModal.cidPinnedWarningLabel().should("be.visible") + }) + + it("can search via name or cid", () => { + cy.web3Login({ withNewSession: true }) + navigationMenu.cidsNavButton().click() + + // use helper to add required pins + // partially sharing the same name, each with unique cid + const pin1 = "Pin 1" + const pin2 = "Pin 2" + cidsPage.addPinnedCid({ name: pin1 }) + cidsPage.addPinnedCid({ name: pin2, cid: testCidAlternative }) - // add a cid - cidsPage.addPinnedCid() + // ensure search by full name yields 1 result + cidsPage.searchCidInput().type(pin1) + cidsPage.cidItemRow().should("have.length", 1) + cidsPage.cidItemRow().within(() => { + cidsPage.cidNameCell().should("have.text", pin1) + }) + + // ensure search by partial name yields 2 results + cidsPage.searchCidInput() + .clear() + .type(pin1.slice(0, 3)) + cidsPage.cidItemRow().should("have.length", 2) + cidsPage.cidItemRow().within(() => { + cidsPage.cidNameCell().should("contain.text", pin1) + cidsPage.cidNameCell().should("contain.text", pin2) + }) - // see warning if attempting to pin the cid again - cidsPage.pinButton().click() - addCidModal.body().should("be.visible") - addCidModal.cidInput().type(testCid) - addCidModal.cidPinnedWarningLabel().should("be.visible") + // ensure search by full cid yields 1 result + cidsPage.searchCidInput() + .clear() + .type(testCid) + cidsPage.cidItemRow().should("have.length", 1) + cidsPage.cidItemRow().within(() => { + cidsPage.cidCell().should("have.text", testCid) + }) + + // peform a search that yields no results + cidsPage.searchCidInput() + .clear() + .type("bogus") + cidsPage.cidItemRow().should("have.length", 0) + + // remove search input to remove all search filtering + cidsPage.searchCidInput().clear() + cidsPage.cidItemRow().should("have.length", 2) + }) }) }) \ No newline at end of file diff --git a/packages/storage-ui/cypress/tests/landing-spec.ts b/packages/storage-ui/cypress/tests/landing-spec.ts new file mode 100644 index 0000000000..05fca7fe6d --- /dev/null +++ b/packages/storage-ui/cypress/tests/landing-spec.ts @@ -0,0 +1,25 @@ +import { authenticationPage } from "../support/page-objects/authenticationPage" +import { localHost } from "../fixtures/loginData" + +describe("Landing", () => { + beforeEach(() => { + cy.visit(localHost) + }) + context("desktop", () => { + + it("can navigate to privacy policy page", () => { + authenticationPage.privacyPolicyLink().invoke('removeAttr', 'target').click() + cy.url().should("include", "/privacy-policy") + }) + + it("can navigate to terms & conditions page", () => { + authenticationPage.termsAndConditionsLink().invoke('removeAttr', 'target').click() + cy.url().should("include", "/terms-of-service") + }) + + it("can navigate to ChainSafe.io from 'Learn more about Chainsafe'", () => { + authenticationPage.learnMoreAboutChainsafeLink().invoke('removeAttr', 'target').click() + cy.url().should("eq", "https://chainsafe.io/") + }) + }) +}) diff --git a/packages/storage-ui/cypress/tests/main-navigation-spec.ts b/packages/storage-ui/cypress/tests/main-navigation-spec.ts index 2133ad3e98..c3e8bda5d3 100644 --- a/packages/storage-ui/cypress/tests/main-navigation-spec.ts +++ b/packages/storage-ui/cypress/tests/main-navigation-spec.ts @@ -1,10 +1,11 @@ import { navigationMenu } from "../support/page-objects/navigationMenu" import { cidsPage } from "../support/page-objects/cidsPage" +import { authenticationPage } from "../support/page-objects/authenticationPage" describe("Main Navigation", () => { context("desktop", () => { - before(() => { + beforeEach(() => { cy.web3Login() }) @@ -12,14 +13,22 @@ describe("Main Navigation", () => { navigationMenu.cidsNavButton().click() cy.url().should("include", "/cids") }) - }) - context("mobile", () => { - before(() => { - cy.web3Login() + it("can sign out from the navigation bar", () => { + navigationMenu.signOutDropdown().click() + navigationMenu.signOutMenuOption() + .should("be.visible") + .click() + authenticationPage.web3Button().should("be.visible") + cy.url().should("not.include", "/drive") + cy.url().should("not.include", "/bin") + cy.url().should("not.include", "/settings") }) + }) + context("mobile", () => { beforeEach(() => { + cy.web3Login() cy.viewport("iphone-6") cidsPage.hamburgerMenuButton().click() }) @@ -28,5 +37,15 @@ describe("Main Navigation", () => { navigationMenu.cidsNavButton().click() cy.url().should("include", "/cids") }) + + it("can sign out from the navigation bar", () => { + navigationMenu.signOutButton() + .should("be.visible") + .click() + authenticationPage.web3Button().should("be.visible") + cy.url().should("not.include", "/drive") + cy.url().should("not.include", "/bin") + cy.url().should("not.include", "/settings") + }) }) }) diff --git a/packages/storage-ui/package.json b/packages/storage-ui/package.json index 0ce787974a..24810e1259 100644 --- a/packages/storage-ui/package.json +++ b/packages/storage-ui/package.json @@ -6,7 +6,7 @@ "@babel/core": "^7.12.10", "@babel/runtime": "^7.0.0", "@chainsafe/browser-storage-hooks": "^1.0.1", - "@chainsafe/files-api-client": "1.18.34", + "@chainsafe/files-api-client": "1.18.35", "@chainsafe/web3-context": "1.3.0", "@emeraldpay/hashicon-react": "0.5.2", "@lingui/core": "^3.7.2", @@ -38,8 +38,6 @@ "react-pdf": "5.3.0", "react-scripts": "3.4.4", "react-swipeable": "^6.0.1", - "react-toast-notifications": "^2.4.0", - "react-use-hotjar": "1.0.8", "react-zoom-pan-pinch": "^1.6.1", "remark-gfm": "^1.0.0", "typescript": "~4.0.5", @@ -61,7 +59,6 @@ "@types/react-beforeunload": "^2.1.0", "@types/react-dom": "^16.9.10", "@types/react-pdf": "^5.0.0", - "@types/react-toast-notifications": "^2.4.0", "@types/yup": "^0.29.9", "@types/zxcvbn": "^4.4.0", "babel-plugin-macros": "^2.8.0", @@ -95,4 +92,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/packages/storage-ui/src/App.tsx b/packages/storage-ui/src/App.tsx index 2755ae95f8..53b91322ca 100644 --- a/packages/storage-ui/src/App.tsx +++ b/packages/storage-ui/src/App.tsx @@ -1,12 +1,11 @@ -import React, { useCallback, useEffect } from "react" -import { init as initSentry, ErrorBoundary, showReportDialog } from "@sentry/react" +import React from "react" +import { init as initSentry, ErrorBoundary } from "@sentry/react" import { Web3Provider } from "@chainsafe/web3-context" import { ThemeSwitcher } from "@chainsafe/common-theme" import "@chainsafe/common-theme/dist/font-faces.css" -import { Button, CssBaseline, Modal, Router, ToastProvider, Typography } from "@chainsafe/common-components" +import { CssBaseline, Router, ToastProvider } from "@chainsafe/common-components" import StorageRoutes from "./Components/StorageRoutes" import AppWrapper from "./Components/Layouts/AppWrapper" -import { useHotjar } from "react-use-hotjar" import { LanguageProvider } from "./Contexts/LanguageContext" import { lightTheme } from "./Themes/LightTheme" import { darkTheme } from "./Themes/DarkTheme" @@ -18,6 +17,14 @@ import { BillingProvider } from "./Contexts/BillingContext" import { NotificationsProvider } from "./Contexts/NotificationsContext" import { PosthogProvider } from "./Contexts/PosthogContext" import { HelmetProvider } from "react-helmet-async" +import ErrorModal from "./Components/Modules/ErrorModal" +import { StylesProvider, createGenerateClassName } from "@material-ui/styles" + +// making material and jss use one className generator +const generateClassName = createGenerateClassName({ + productionPrefix: "c", + disableGlobal: true +}) if ( process.env.NODE_ENV === "production" && @@ -61,86 +68,57 @@ const onboardConfig = { } const App = () => { - const { initHotjar } = useHotjar() const { canUseLocalStorage } = useLocalStorage() - const hotjarId = process.env.REACT_APP_HOTJAR_ID const apiUrl = process.env.REACT_APP_API_URL || "https://stage-api.chainsafe.io/api/v1" // This will default to testnet unless mainnet is specifically set in the ENV - useEffect(() => { - if (hotjarId && process.env.NODE_ENV === "production") { - initHotjar(hotjarId, "6", () => console.log("Hotjar initialized")) - } - }, [hotjarId, initHotjar]) - - const fallBack = useCallback(({ error, componentStack, eventId, resetError }) => ( - - - An error occurred and has been logged. If you would like to - provide additional info to help us debug and resolve the issue, - click the `"`Provide Additional Details`"` button - - {error?.message.toString()} - {componentStack} - {eventId} - - - - ), []) - return ( - - window.location.reload()} + + - - - - - window.location.reload()} + > + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/packages/storage-ui/src/Components/Elements/BucketRow.tsx b/packages/storage-ui/src/Components/Elements/BucketRow.tsx index 5d7406b852..1b920e0b38 100644 --- a/packages/storage-ui/src/Components/Elements/BucketRow.tsx +++ b/packages/storage-ui/src/Components/Elements/BucketRow.tsx @@ -83,7 +83,8 @@ const BucketRow = ({ bucket }: Props) => { onClick={() => redirect(ROUTE_LINKS.Bucket(bucket.id, "/"))}> {bucket.name || bucket.id} - + {bucket.file_system_type === "ipfs" ? "IPFS MFS" : "Chainsafe" } diff --git a/packages/storage-ui/src/Components/Elements/CidRow.tsx b/packages/storage-ui/src/Components/Elements/CidRow.tsx index 64b742d2d9..796ee09df0 100644 --- a/packages/storage-ui/src/Components/Elements/CidRow.tsx +++ b/packages/storage-ui/src/Components/Elements/CidRow.tsx @@ -88,6 +88,7 @@ const CidRow = ({ pinStatus }: Props) => { {pinStatus.pin?.cid} diff --git a/packages/storage-ui/src/Components/Elements/CustomModal.tsx b/packages/storage-ui/src/Components/Elements/CustomModal.tsx index 4f67eee94a..7813d738dc 100644 --- a/packages/storage-ui/src/Components/Elements/CustomModal.tsx +++ b/packages/storage-ui/src/Components/Elements/CustomModal.tsx @@ -12,21 +12,24 @@ const useStyles = makeStyles(({ constants, breakpoints }: CSSTheme) => } }, inner: { + backgroundColor: constants.modalDefault.backgroundColor, + color: constants.modalDefault.color, + width: "100%" + }, + mobileStickyBottom: { [breakpoints.down("md")]: { - backgroundColor: constants.modalDefault.background, + position: "fixed", top: "unset", bottom: 0, left: 0, width: "100% !important", - transform: "unset", - borderRadiusLeftTop: `${constants.generalUnit * 1.5}px`, - borderRadiusRightTop: `${constants.generalUnit * 1.5}px`, - borderRadiusLeftBottom: 0, - borderRadiusRightBottom: 0 + transform: "unset" } }, - closeIcon: { - [breakpoints.down("md")]: {} + closeIcon : { + "& svg": { + stroke: constants.modalDefault.closeIconColor + } } }) ) @@ -34,14 +37,11 @@ const useStyles = makeStyles(({ constants, breakpoints }: CSSTheme) => interface ICustomModal extends IModalProps { children: ReactNode className?: string + testId?: string + mobileStickyBottom?: boolean } -const CustomModal: React.FC = ({ - className, - children, - injectedClass, - ...rest -}: ICustomModal) => { +const CustomModal = ({ className, children, injectedClass, mobileStickyBottom = true, ...rest }: ICustomModal) => { const classes = useStyles() return ( @@ -49,7 +49,8 @@ const CustomModal: React.FC = ({ className={clsx(classes.root, className)} injectedClass={{ closeIcon: clsx(classes.closeIcon, injectedClass?.closeIcon), - inner: clsx(classes.inner, injectedClass?.inner) + inner: clsx(classes.inner, mobileStickyBottom ? classes.mobileStickyBottom : undefined, injectedClass?.inner), + subModalInner: injectedClass?.subModalInner }} {...rest} > diff --git a/packages/storage-ui/src/Components/Layouts/AppHeader.tsx b/packages/storage-ui/src/Components/Layouts/AppHeader.tsx index 7f7713e9f3..2e7158a30d 100644 --- a/packages/storage-ui/src/Components/Layouts/AppHeader.tsx +++ b/packages/storage-ui/src/Components/Layouts/AppHeader.tsx @@ -8,7 +8,8 @@ import { HamburgerMenu, MenuDropdown, PowerDownSvg, - useHistory + useHistory, + useToasts } from "@chainsafe/common-components" import { ROUTE_LINKS } from "../StorageRoutes" import { Trans } from "@lingui/macro" @@ -177,13 +178,15 @@ const AppHeader = ({ navOpen, setNavOpen }: IAppHeader) => { const { isLoggedIn, logout } = useStorageApi() const { history } = useHistory() const { getProfileTitle, profile } = useUser() + const { removeAllToasts } = useToasts() const profileTitle = getProfileTitle() const signOut = useCallback(async () => { logout() + removeAllToasts() history.replace("/", {}) - }, [logout, history]) + }, [logout, removeAllToasts, history]) return (
    = ({ navOpen, setNavOpen }: IAppNav) => {
    +
    + +} + +export default ErrorModal \ No newline at end of file diff --git a/packages/storage-ui/src/Components/Modules/FileSystemItem/FileSystemGridItem.tsx b/packages/storage-ui/src/Components/Modules/FileSystemItem/FileSystemGridItem.tsx index 0a9284ed11..6cbae89238 100644 --- a/packages/storage-ui/src/Components/Modules/FileSystemItem/FileSystemGridItem.tsx +++ b/packages/storage-ui/src/Components/Modules/FileSystemItem/FileSystemGridItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { makeStyles, createStyles, useThemeSwitcher } from "@chainsafe/common-theme" import { t } from "@lingui/macro" import clsx from "clsx" @@ -7,7 +7,8 @@ import { IMenuItem, Loading, MenuDropdown, - MoreIcon + MoreIcon, + Typography } from "@chainsafe/common-components" import { CSSTheme } from "../../../Themes/types" import { FileSystemItem } from "../../../Contexts/StorageContext" @@ -15,6 +16,7 @@ import { ConnectDragPreview } from "react-dnd" import { Form, FormikProvider, useFormik } from "formik" import { nameValidator } from "../../../Utils/validationSchema" import { ISelectedFile } from "../../../Contexts/FileBrowserContext" +import { getFileNameAndExtension } from "../../../Utils/Helpers" const useStyles = makeStyles(({ breakpoints, constants, palette }: CSSTheme) => { return createStyles({ @@ -76,9 +78,15 @@ const useStyles = makeStyles(({ breakpoints, constants, palette }: CSSTheme) => desktopRename: { display: "flex", flexDirection: "row", + alignItems: "center", "& svg": { width: 20, height: 20 + }, + "& > span": { + fontSize: 16, + lineHeight: "20px", + marginLeft: constants.generalUnit / 2 } }, dropdownIcon: { @@ -130,12 +138,12 @@ interface IFileSystemTableItemProps { isOverUpload: boolean selected: ISelectedFile[] file: FileSystemItem - editing?: string + editingFile?: ISelectedFile onFolderOrFileClicks: (e?: React.MouseEvent) => void icon: React.ReactNode preview: ConnectDragPreview - setEditing: (editing: ISelectedFile | undefined) => void - handleRename?: (cid: string, newName: string) => Promise | undefined + setEditingFile: (editingFile: ISelectedFile | undefined) => void + handleRename?: (item: ISelectedFile, newName: string) => Promise | undefined currentPath: string | undefined menuItems: IMenuItem[] resetSelectedFiles: () => void @@ -148,10 +156,10 @@ const FileSystemGridItem = React.forwardRef( isOverUpload, selected, file, - editing, + editingFile, onFolderOrFileClicks, icon, - setEditing, + setEditingFile, handleRename, menuItems, resetSelectedFiles, @@ -162,18 +170,22 @@ const FileSystemGridItem = React.forwardRef( const { desktop } = useThemeSwitcher() const [isEditingLoading, setIsEditingLoading] = useState(false) + const { fileName, extension } = useMemo(() => { + return getFileNameAndExtension(name, isFolder) + }, [name, isFolder]) + const formik = useFormik({ initialValues: { - fileName: name + name: fileName }, validationSchema: nameValidator, onSubmit: (values) => { - const newName = values.fileName?.trim() + const newName = extension !== "" ? `${values.name.trim()}.${extension}` : values.name.trim() - if (newName !== name && !!newName && handleRename) { + if (newName !== name && !!newName && handleRename && editingFile) { setIsEditingLoading(true) - handleRename(file.cid, newName) + handleRename(editingFile, newName) ?.then(() => setIsEditingLoading(false)) } else { stopEditing() @@ -205,9 +217,9 @@ const FileSystemGridItem = React.forwardRef( }, [handleClickOutside]) const stopEditing = useCallback(() => { - setEditing(undefined) + setEditingFile(undefined) formik.resetForm() - }, [formik, setEditing]) + }, [formik, setEditingFile]) return (
    {icon}
    - {editing === cid && desktop ? ( - + {/* checking the name is useful for MFS folders since empty folders all have the same cid */} + {editingFile?.cid === cid && editingFile.name === name && desktop + ? (
    { if (event.key === "Escape") { @@ -255,15 +268,22 @@ const FileSystemGridItem = React.forwardRef( } autoFocus /> + { + !isFolder && extension !== "" && ( + + { `.${extension}` } + + ) + }
    - ) :
    - {name}{isEditingLoading && } -
    + ) :
    + {name}{isEditingLoading && } +
    }
    { return createStyles({ @@ -61,6 +59,22 @@ const useStyles = makeStyles(({ breakpoints, constants }: CSSTheme) => { renameHeader: { textAlign: "center" }, + renameInputWrapper: { + display: "flex", + flexDirection: "row", + alignItems: "flex-end", + [breakpoints.down("md")]: { + margin: `${constants.generalUnit * 4.2}px 0` + }, + "& > span": { + display: "block", + fontSize: 16, + lineHeight: "20px", + marginLeft: constants.generalUnit / 2, + marginBottom: (constants.generalUnit * 2.50), + transform: "translateY(50%)" + } + }, renameFooter: { display: "flex", flexDirection: "row", @@ -107,9 +121,10 @@ interface IFileSystemItemProps { selected: ISelectedFile[] handleSelectCid(selectedFile: ISelectedFile): void handleAddToSelectedCids(selectedFile: ISelectedFile): void - editing?: string - setEditing(editing: ISelectedFile | undefined): void - handleRename?: (cid: string, newName: string) => Promise | undefined + handleSelectItemWithShift(selectedFile: ISelectedFile): void + editingFile?: ISelectedFile + setEditingFile(editingFile: ISelectedFile | undefined): void + handleRename?: (item: ISelectedFile, newName: string) => Promise | undefined handleMove?: (toMove: ISelectedFile, newPath: string) => Promise deleteFile?: () => void recoverFile?: (toRecover: ISelectedFile) => void @@ -126,8 +141,8 @@ const FileSystemItem = ({ file, files, selected, - editing, - setEditing, + editingFile, + setEditingFile, handleRename, deleteFile, recoverFile, @@ -137,37 +152,41 @@ const FileSystemItem = ({ setFileInfoPath, handleSelectCid, handleAddToSelectedCids, + handleSelectItemWithShift, itemOperations, browserView, resetSelectedFiles }: IFileSystemItemProps) => { const { downloadFile, currentPath, handleUploadOnDrop, moveItems } = useFileBrowser() - const { cid, name, isFolder, content_type } = file - let Icon - if (isFolder) { - Icon = FolderFilledSvg - } else if (content_type.includes("image")) { - Icon = FileImageSvg - } else if (content_type.includes("pdf")) { - Icon = FilePdfSvg - } else { - Icon = FileTextSvg - } + const { cid, name, isFolder } = file const { desktop } = useThemeSwitcher() const classes = useStyles() + const { fileName, extension } = useMemo(() => { + return getFileNameAndExtension(name, isFolder) + }, [name, isFolder]) + const formik = useFormik({ - initialValues: { name }, + initialValues: { name: fileName }, validationSchema: nameValidator, - onSubmit: (values: {name: string}) => { - const newName = values.name.trim() + onSubmit: (values: { name: string }) => { + const newName = extension !== "" ? `${values.name.trim()}.${extension}` : values.name.trim() - editing && newName && handleRename && handleRename(editing, newName) + if (newName !== name && editingFile) { + newName && handleRename && handleRename(editingFile, newName) + } else { + stopEditing() + } }, enableReinitialize: true }) + const stopEditing = useCallback(() => { + setEditingFile(undefined) + formik.resetForm() + }, [formik, setEditingFile]) + const allMenuItems: Record = { rename: { contents: ( @@ -178,7 +197,7 @@ const FileSystemItem = ({ ), - onClick: () => setEditing({ cid, name }) + onClick: () => setEditingFile({ cid, name }) }, delete: { contents: ( @@ -283,19 +302,20 @@ const FileSystemItem = ({ (itemOperation) => allMenuItems[itemOperation] ) - const [, dragMoveRef, preview] = useDrag(() => - ({ type: DragTypes.MOVABLE_FILE, - item: () => { - if (selected.findIndex(item => item.cid === file.cid && item.name === file.name) >= 0) { - return { selected: selected } - } else { - return { selected: [...selected, { - cid: file.cid, - name: file.name - }] } - } + const [, dragMoveRef, preview] = useDrag({ + type: DragTypes.MOVABLE_FILE, + canDrag: !editingFile, + item: () => { + if (selected.findIndex(item => item.cid === file.cid && item.name === file.name) >= 0) { + return { selected: selected } + } else { + return { selected: [...selected, { + cid: file.cid, + name: file.name + }] } } - }), [selected]) + } + }) useEffect(() => { // This gets called after every render, by default @@ -311,17 +331,21 @@ const FileSystemItem = ({ const [{ isOverMove }, dropMoveRef] = useDrop({ accept: DragTypes.MOVABLE_FILE, - canDrop: () => isFolder, + canDrop: (item) => isFolder && + item.selected.findIndex((s) => s.cid === file.cid && s.name === file.name) < 0, drop: (item: {selected: ISelectedFile[]}) => { moveItems && moveItems(item.selected, getPathWithFile(currentPath, name)) + resetSelectedFiles() }, collect: (monitor) => ({ - isOverMove: monitor.isOver() + isOverMove: monitor.isOver() && + monitor.getItem<{selected: ISelectedFile[]}>().selected.findIndex((s) => s.cid === file.cid && s.name === file.name) < 0 }) }) const [{ isOverUpload }, dropUploadRef] = useDrop({ accept: [NativeTypes.FILE], + canDrop: () => isFolder, drop: (item: any) => { handleUploadOnDrop && handleUploadOnDrop(item.files, item.items, getPathWithFile(currentPath, name)) @@ -333,46 +357,67 @@ const FileSystemItem = ({ const fileOrFolderRef = useRef() - if (!editing && isFolder) { - dropMoveRef(fileOrFolderRef) - dropUploadRef(fileOrFolderRef) + if (fileOrFolderRef?.current) { + if (editingFile) { + fileOrFolderRef.current.draggable = false + } else { + fileOrFolderRef.current.draggable = true + } } - if (!editing && !isFolder) { + + if (!editingFile && desktop) { dragMoveRef(fileOrFolderRef) + if (isFolder) { + dropMoveRef(fileOrFolderRef) + dropUploadRef(fileOrFolderRef) + } } const onFilePreview = useCallback(() => { setPreviewFileIndex(files?.indexOf(file)) }, [file, files, setPreviewFileIndex]) + const handleItemSelectOnCheck = useCallback((e: React.MouseEvent) => { + if (e && (e.ctrlKey || e.metaKey)) { + handleAddToSelectedCids({ cid, name }) + } else if (e && (e.shiftKey || e.metaKey)) { + handleSelectItemWithShift({ cid, name }) + } else { + handleAddToSelectedCids({ cid, name }) + } + }, [handleAddToSelectedCids, handleSelectItemWithShift, cid, name]) + const onSingleClick = useCallback( (e) => { if (desktop) { // on desktop if (e && (e.ctrlKey || e.metaKey)) { - handleAddToSelectedCids({ - cid, - name - }) + handleAddToSelectedCids({ cid, name }) + } else if (e && (e.shiftKey || e.metaKey)) { + handleSelectItemWithShift({ cid, name }) } else { - handleSelectCid({ - cid, - name - }) + handleSelectCid({ cid, name }) } } else { // on mobile if (isFolder) { - viewFolder && viewFolder({ - cid, - name - }) + viewFolder && viewFolder({ cid, name }) } else { onFilePreview() } } }, - [cid, handleSelectCid, handleAddToSelectedCids, desktop, isFolder, viewFolder, name, onFilePreview] + [ + cid, + desktop, + isFolder, + handleAddToSelectedCids, + handleSelectItemWithShift, + handleSelectCid, + viewFolder, + name, + onFilePreview + ] ) const onDoubleClick = useCallback( @@ -402,10 +447,12 @@ const FileSystemItem = ({ click(e) } + const Icon = getIconForItem(file) + const itemProps = { ref: fileOrFolderRef, currentPath, - editing, + editingFile, file, handleAddToSelectedCids, handleRename, @@ -417,8 +464,9 @@ const FileSystemItem = ({ onFolderOrFileClicks, preview, selected, - setEditing, - resetSelectedFiles + setEditingFile, + resetSelectedFiles, + handleItemSelectOnCheck } return ( @@ -429,7 +477,7 @@ const FileSystemItem = ({ : } { - editing === cid && !desktop && ( + editingFile?.cid === cid && editingFile?.name === name && !desktop && ( <> setEditing(undefined)} + active={!!editingFile} + onClose={stopEditing} >
    @@ -451,16 +500,25 @@ const FileSystemItem = ({ : Rename file } - +
    + + { + !isFolder && extension !== "" && ( + + { `.${extension}` } + + ) + } +
    +
    {crumbs && moduleRootPath && ( ({ + ...crumb, + component: (i < crumbs.length - 1) + ? { + console.log(item, crumb.path) + moveItems && crumb.path && moveItems(item.selected, crumb.path) + resetSelectedCids() + }} + handleUpload={(item) => handleUploadOnDrop && + crumb.path && + handleUploadOnDrop(item.files, item.items, crumb.path) + } + /> + : null + }))} homeOnClick={() => redirect(moduleRootPath)} - showDropDown={!desktop} + homeRef={homeBreadcrumbRef} + homeActive={isOverUploadHomeBreadcrumb || isOverMoveHomeBreadcrumb} + showDropDown + maximumCrumbs={desktop ? 5 : 3} /> )}
    { {controls && desktop ? ( <>