diff --git a/.gitignore b/.gitignore index 51c77004f7..a02bc6e2a0 100644 --- a/.gitignore +++ b/.gitignore @@ -290,10 +290,7 @@ temp/ .AppleDouble .LSOverride -# Icon must end with two \r -Icon - -# Thumbnails +#Thumbnails ._* # Files that might appear in the root of a volume @@ -340,3 +337,6 @@ $RECYCLE.BIN/ /npm/react/bin/* # End of https://www.gitignore.io/api/osx,git,node,windows,intellij,linux + +# Circle cache artifacts +globbed_node_modules diff --git a/DEPLOY.md b/DEPLOY.md index 3f3d7fa1f4..4f61395e80 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -126,7 +126,10 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress ``` - Test the new version of Cypress against the Cypress dashboard repo. -7. Deploy the release-specific documentation and changelog in [cypress-documentation](https://github.com/cypress-io/cypress-documentation). + +7. Confirm that every issue labeled [stage: pending release](https://github.com/cypress-io/cypress/issues?q=label%3A%22stage%3A+pending+release%22+is%3Aclosed) has a ZenHub release set. **Tip:** there is a command in [`release-automations`](https://github.com/cypress-io/release-automations)'s `issues-in-release` tool to list and check such issues. Without a ZenHub release issues will not be included in the right changelog. + +8. Deploy the release-specific documentation and changelog in [cypress-documentation](https://github.com/cypress-io/cypress-documentation). - If there is not already a release-specific PR open, create one. You can use [`release-automations`](https://github.com/cypress-io/release-automations)'s `issues-in-release` tool to generate a starting point for the changelog, based off of ZenHub: ``` cd packages/issues-in-release @@ -136,26 +139,26 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress - Merge any release-specific documentation changes into the main release PR. - Merging this PR into `develop` will deploy to `docs-staging` and then a PR will be automatically created against `master`. It will be automatically merged after it passes and will deploy to production. -8. Make the new npm version the "latest" version by updating the dist-tag `latest` to point to the new version: +9. Make the new npm version the "latest" version by updating the dist-tag `latest` to point to the new version: ```shell npm dist-tag add cypress@X.Y.Z ``` -9. Run `binary-release` to update the [download server's manifest](https://download.cypress.io/desktop.json): +10. Run `binary-release` to update the [download server's manifest](https://download.cypress.io/desktop.json): ```shell yarn run binary-release --version X.Y.Z ``` -10. If needed, push out any updated changes to the links manifest to [`on.cypress.io`](https://github.com/cypress-io/cypress-services/tree/develop/packages/on). +11. If needed, push out any updated changes to the links manifest to [`on.cypress.io`](https://github.com/cypress-io/cypress-services/tree/develop/packages/on). -11. If needed, deploy the updated [`cypress-example-kitchensink`][cypress-example-kitchensink] to `example.cypress.io` by following [these instructions under "Deployment"](./packages/example/README.md). +12. If needed, deploy the updated [`cypress-example-kitchensink`][cypress-example-kitchensink] to `example.cypress.io` by following [these instructions under "Deployment"](./packages/example/README.md). -12. Update the releases in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release): +13. Update the releases in [ZenHub](https://app.zenhub.com/workspaces/test-runner-5c3ea3baeb1e75374f7b0708/reports/release): - Close the current release in ZenHub. - Create a new patch release (and a new minor release, if this is a minor release) in ZenHub, and schedule them both to be completed 2 weeks from the current date. - Move all issues that are still open from the current release to the appropriate future release. -13. Bump `version` in [`package.json`](package.json), commit it to `develop`, tag it with the version, and push the tag up: +14. Bump `version` in [`package.json`](package.json), commit it to `develop`, tag it with the version, and push the tag up: ```shell git commit -am "release X.Y.Z [skip ci]" git log --pretty=oneline @@ -163,7 +166,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress git tag -a vX.Y.Z git push origin vX.Y.Z ``` -14. Merge `develop` into `master` and push both branches up. Note: pushing to `master` will automatically publish any independent npm packages that have not yet been published. +15. Merge `develop` into `master` and push both branches up. Note: pushing to `master` will automatically publish any independent npm packages that have not yet been published. ```shell git push origin develop git checkout master @@ -171,7 +174,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress git push origin master ``` -15. Inside of [cypress-io/release-automations][release-automations]: +16. Inside of [cypress-io/release-automations][release-automations]: - Publish GitHub release to [cypress-io/cypress/releases](https://github.com/cypress-io/cypress/releases) using package `set-releases`: ```shell cd packages/set-releases && npm run release-log -- --version X.Y.Z @@ -180,10 +183,11 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress ```shell cd packages/issues-in-release && npm run do:comment -- --release X.Y.Z ``` + - Confirm there are no issues with the label [stage: pending release](https://github.com/cypress-io/cypress/issues?q=label%3A%22stage%3A+pending+release%22+is%3Aclosed) left -16. Publish a new docker image in [`cypress-docker-images`](https://github.com/cypress-io/cypress-docker-images) under `included` for the new cypress version. +17. Publish a new docker image in [`cypress-docker-images`](https://github.com/cypress-io/cypress-docker-images) under `included` for the new cypress version. Note: we use the base image with the Node version matching the bundled Node version. -17. Update example projects to the new version. For most projects, you can go to the Renovate dependency issue and check the box next to `Update dependency cypress to X.Y.Z`. It will automatically create a PR. Once it passes, you can merge it. Try updating at least the following projects: +18. Update example projects to the new version. For most projects, you can go to the Renovate dependency issue and check the box next to `Update dependency cypress to X.Y.Z`. It will automatically create a PR. Once it passes, you can merge it. Try updating at least the following projects: - [cypress-example-todomvc](https://github.com/cypress-io/cypress-example-todomvc/issues/99) - [cypress-example-todomvc-redux](https://github.com/cypress-io/cypress-example-todomvc-redux/issues/1) - [cypress-example-realworld](https://github.com/cypress-io/cypress-example-realworld/issues/2) @@ -195,7 +199,7 @@ In the following instructions, "X.Y.Z" is used to denote the version of Cypress - [cypress-documentation](https://github.com/cypress-io/cypress-documentation/issues/1313) - [cypress-example-docker-compose](https://github.com/cypress-io/cypress-example-docker-compose) - Doesn't have a Renovate issue, but will auto-create and auto-merge non-major Cypress updates as long as the tests pass. -18. Check if any test or example repositories have a branch for testing the features or fixes from the newly published version `x.y.z`. The branch should also be named `x.y.z`. Check all `cypress-test-*` and `cypress-example-*` repositories, and if there is a branch named `x.y.z`, merge it into `master`. +19. Check if any test or example repositories have a branch for testing the features or fixes from the newly published version `x.y.z`. The branch should also be named `x.y.z`. Check all `cypress-test-*` and `cypress-example-*` repositories, and if there is a branch named `x.y.z`, merge it into `master`. **Test Repos** diff --git a/circle.yml b/circle.yml index 669e5160af..efe495b4c3 100644 --- a/circle.yml +++ b/circle.yml @@ -18,6 +18,13 @@ defaults: &defaults executor: type: executor default: cy-doc + is-mac: + type: boolean + default: false + arch: + type: enum + default: 'linux' + enum: ['linux', 'darwin'] executor: <> environment: ## set specific timezone @@ -31,7 +38,7 @@ defaults: &defaults LINES: 24 # filters and requires for testing binary with Firefox -testBinaryFirefox: &testBinaryFirefox +onlyMainBranches: &onlyMainBranches filters: branches: only: @@ -74,6 +81,187 @@ executors: PLATFORM: mac commands: + restore_workspace_binaries: + steps: + - attach_workspace: + at: ~/ + # make sure we have cypress.zip received + - run: ls -l + - run: ls -l cypress.zip cypress.tgz + - run: node --version + - run: npm --version + + restore_cached_workspace: + steps: + - attach_workspace: + at: ~/ + - unpack-dependencies + + prepare-modules-cache: + parameters: + dont-move: + type: boolean + default: false + steps: + - run: node scripts/circle-cache.js --action prepare + - unless: + condition: << parameters.dont-move >> + steps: + - run: + name: Move to /tmp dir for consistent caching across root/non-root users + command: | + mkdir -p /tmp/node_modules_cache + mv ~/cypress/node_modules /tmp/node_modules_cache/root_node_modules + mv ~/cypress/cli/node_modules /tmp/node_modules_cache/cli_node_modules + mv ~/cypress/globbed_node_modules /tmp/node_modules_cache/globbed_node_modules + + build-and-persist: + description: Save entire folder as artifact for other jobs to run without reinstalling + steps: + - run: + name: Build packages + command: yarn build + - prepare-modules-cache # So we don't throw these in the workspace cache + - persist_to_workspace: + root: ~/ + paths: + - cypress + - .ssh + - node_modules # contains the npm i -g modules + + unpack-dependencies: + description: 'Unpacks dependencies associated with the current workflow' + steps: + - run: + name: Generate Circle Cache Key + command: node scripts/circle-cache.js --action cacheKey > circle_cache_key + - restore_cache: + key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-{{ checksum "circle_cache_key" }} + - run: + name: Move node_modules back from /tmp + command: | + if [[ -d "/tmp/node_modules_cache" ]]; then + mv /tmp/node_modules_cache/root_node_modules ~/cypress/node_modules + mv /tmp/node_modules_cache/cli_node_modules ~/cypress/cli/node_modules + mv /tmp/node_modules_cache/globbed_node_modules ~/cypress/globbed_node_modules + rm -rf /tmp/node_modules_cache + fi + - run: + name: Restore all node_modules to proper workspace folders + command: node scripts/circle-cache.js --action unpack + + caching-dependency-installer: + description: 'Installs & caches the dependencies based on yarn lock & package json dependencies' + parameters: + is-mac: + type: boolean + default: false + arch: + type: enum + enum: ['linux', 'darwin'] + steps: + - run: + name: Generate Circle Cache Key + command: node scripts/circle-cache.js --action cacheKey > circle_cache_key + - restore_cache: + name: Restore cache state, to check for known modules cache existence + key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-state-{{ checksum "circle_cache_key" }} + - run: + name: Bail if cache exists + command: | + if [[ -f "node_modules_installed" ]]; then + echo "Node modules already cached for dependencies, exiting" + circleci-agent step halt + fi + - run: date +%Y-%U > cache_date + - restore_cache: + name: Restore weekly yarn cache + keys: + - v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-deps-root-weekly-{{ checksum "cache_date" }} + - run: + name: Install Node Modules + command: | + . ./scripts/load-nvm.sh + yarn --prefer-offline --frozen-lockfile --cache-folder ~/.yarn-<< parameters.arch >> + no_output_timeout: 20m + - prepare-modules-cache: + dont-move: <> # we don't move, so we don't hit any issues unpacking symlinks + - when: + condition: <> # on mac, we don't move to /tmp since we don't need to worry about different users + steps: + - save_cache: + name: Saving node modules for root, cli, and all globbed workspace packages + key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-{{ checksum "circle_cache_key" }} + paths: + - node_modules + - cli/node_modules + - globbed_node_modules + - unless: + condition: <> + steps: + - save_cache: + name: Saving node modules for root, cli, and all globbed workspace packages + key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-{{ checksum "circle_cache_key" }} + paths: + - /tmp/node_modules_cache + - run: touch node_modules_installed + - save_cache: + name: Saving node-modules cache state key + key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-node-modules-cache-state-{{ checksum "circle_cache_key" }} + paths: + - node_modules_installed + - save_cache: + name: Save weekly yarn cache + key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-test2-deps-root-weekly-{{ checksum "cache_date" }} + paths: + - ~/.yarn-<< parameters.arch >> + + install-build-setup: + description: Common commands run when setting up for build or yarn install + steps: + - run: + name: Print working folder + command: echo $PWD + - run: + name: print global yarn cache path + command: echo $(yarn global bin) + - run: + name: print Node version + command: | + . ./scripts/load-nvm.sh + echo "nvm use default" + nvm use default + node -v + - run: + name: print yarn version + command: yarn -v + - run: + name: check Node version + command: | + . ./scripts/load-nvm.sh + yarn check-node-version + + ## make sure the TERM is set to 'xterm' in node (Linux only) + ## else colors (and tests) will fail + ## See the following information + ## * http://andykdocs.de/development/Docker/Fixing+the+Docker+TERM+variable+issue + ## * https://unix.stackexchange.com/questions/43945/whats-the-difference-between-various-term-variables + - run: + name: Check terminal + command: | + . ./scripts/load-nvm.sh + yarn check-terminal + - run: + name: Stop .only + command: | + . ./scripts/load-nvm.sh + yarn stop-only-all + - run: + # Deps needed by circle-cache.js, before we "yarn" or unpack cached node_modules + name: Cache Helper Deps + working_directory: ~/ + command: npm i globby fs-extra minimist fast-json-stable-stringify + install-required-node: # https://discuss.circleci.com/t/switch-nodejs-version-on-machine-executor-solved/26675/2 description: Install Node version matching .node-version @@ -126,8 +314,7 @@ commands: description: browser shortname to target type: string steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: environment: CYPRESS_KONFIG_ENV: production @@ -167,8 +354,7 @@ commands: type: boolean default: false steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: | cmd=$([[ <> == 'true' ]] && echo 'yarn percy exec --') || true @@ -194,8 +380,7 @@ commands: type: boolean default: false steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: | cmd=$([[ <> == 'true' ]] && echo 'yarn percy exec --') || true @@ -223,8 +408,7 @@ commands: description: browser shortname to target type: string steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: yarn workspace @packages/server test ./test/e2e/$(( $CIRCLE_NODE_INDEX ))_*spec* --browser <> - verify-mocha-results @@ -273,8 +457,7 @@ commands: description: "Name of the github repo to clone like: cypress-example-kitchensink" type: string steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: "Cloning test project: <>" command: | @@ -321,11 +504,6 @@ commands: type: string default: "CI=true yarn start" steps: - - attach_workspace: - at: ~/ - # make sure the binary and NPM package files are present - - run: ls -l - - run: ls -l cypress.zip cypress.tgz - clone-repo-and-checkout-release-branch: repo: <> - when: @@ -456,11 +634,6 @@ commands: type: string default: "npm start --if-present" steps: - - attach_workspace: - at: ~/ - # make sure the binary and NPM package files are present - - run: ls -l - - run: ls -l cypress.zip cypress.tgz - clone-repo-and-checkout-release-branch: repo: <> - when: @@ -474,16 +647,16 @@ commands: git checkout pr-<> git log -n 2 - run: - # Install with yarn if yarn.lock present + # Install deps + Cypress binary with yarn if yarn.lock present command: | - [[ -f yarn.lock ]] && yarn || npm install + if [[ -f yarn.lock ]]; then + yarn --frozen-lockfile + CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip yarn add -D ~/cypress/cypress.tgz + else + npm install + CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm install ~/cypress/cypress.tgz + fi working_directory: /tmp/<> - - run: - name: Install Cypress - working_directory: /tmp/<> - # force installing the freshly built binary - command: | - CYPRESS_INSTALL_BINARY=~/cypress/cypress.zip npm i ~/cypress/cypress.tgz - run: name: Print Cypress version working_directory: /tmp/<> @@ -685,90 +858,37 @@ commands: - cypress/npm-package-url.json jobs: - ## code checkout and yarn installs + ## Checks if we already have a valid cache for the node_modules_install and if it has, + ## skips ahead to the build step, otherwise installs and caches the node_modules + node_modules_install: + <<: *defaults + steps: + - checkout + - install-required-node + - install-build-setup + - caching-dependency-installer: + arch: << parameters.arch >> + is-mac: <> + - store-npm-logs + + ## restores node_modules from previous step & builds if first step skipped build: <<: *defaults steps: - checkout - install-required-node - - run: - name: Print working folder - command: echo $PWD - - run: - name: print global yarn cache path - command: echo $(yarn global bin) - - run: - name: print Node version - command: | - . ./scripts/load-nvm.sh - echo "nvm use default" - nvm use default - node -v - - run: - name: print yarn version - command: yarn -v - - run: - name: check Node version - command: | - . ./scripts/load-nvm.sh - yarn check-node-version - - ## make sure the TERM is set to 'xterm' in node (Linux only) - ## else colors (and tests) will fail - ## See the following information - ## * http://andykdocs.de/development/Docker/Fixing+the+Docker+TERM+variable+issue - ## * https://unix.stackexchange.com/questions/43945/whats-the-difference-between-various-term-variables - - run: - name: Check terminal - command: | - . ./scripts/load-nvm.sh - yarn check-terminal - - - run: - name: Stop .only - command: | - . ./scripts/load-nvm.sh - yarn stop-only-all - - - restore_cache: - name: Restore yarn cache - key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-deps-root-{{ checksum "yarn.lock" }} - - # show what is already cached globally - - run: ls $(yarn global bin) - - run: ls $(yarn global bin)/../lib/node_modules - - # try several times, because flaky NPM installs ... - - run: - name: install and build - command: | - . ./scripts/load-nvm.sh - yarn --frozen-lockfile || yarn --frozen-lockfile - yarn build-prod + - install-build-setup + - unpack-dependencies - run: name: Top level packages command: yarn list --depth=0 || true - + - build-and-persist - store-npm-logs - - save_cache: - name: Save yarn cache - key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-deps-root-{{ checksum "yarn.lock" }} - paths: - - ~/.cache - - ## save entire folder as artifact for other jobs to run without reinstalling - - persist_to_workspace: - root: ~/ - paths: - - cypress - - .ssh - lint: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - install-required-node ## this will catch ".only"s in js/coffee as well - run: @@ -791,8 +911,7 @@ jobs: required_env_var: type: env_var_name steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: # if this is an external pull request, the environment variables # are NOT set for security reasons, thus no need to poll - @@ -819,8 +938,7 @@ jobs: <<: *defaults parallelism: 8 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: mkdir -p cli/visual-snapshots - run: command: node cli/bin/cypress info --dev | yarn --silent term-to-html | node scripts/sanitize --type cli-info > cli/visual-snapshots/cypress-info.html @@ -843,8 +961,7 @@ jobs: <<: *defaults parallelism: 1 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace # make sure mocha runs - run: yarn test-mocha # test binary build code @@ -870,8 +987,7 @@ jobs: <<: *defaults parallelism: 1 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: ls -la types working_directory: cli @@ -890,8 +1006,7 @@ jobs: <<: *defaults parallelism: 1 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: yarn test-unit --scope @packages/server - verify-mocha-results: expectedResultCount: 1 @@ -903,8 +1018,7 @@ jobs: <<: *defaults parallelism: 1 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: yarn test-unit --scope @packages/server-ct - verify-mocha-results: expectedResultCount: 1 @@ -916,8 +1030,7 @@ jobs: <<: *defaults parallelism: 1 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: yarn test-integration --scope @packages/server - verify-mocha-results: expectedResultCount: 1 @@ -928,8 +1041,7 @@ jobs: server-performance-tests: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: yarn workspace @packages/server test-performance - verify-mocha-results: @@ -964,8 +1076,7 @@ jobs: server-e2e-tests-non-root: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: yarn workspace @packages/server test ./test/e2e/non_root*spec* --browser electron - verify-mocha-results @@ -1016,8 +1127,7 @@ jobs: <<: *defaults parallelism: 2 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: yarn build-prod working_directory: packages/desktop-gui @@ -1042,8 +1152,7 @@ jobs: <<: *defaults parallelism: 1 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: # will use PERCY_TOKEN environment variable if available command: | @@ -1060,8 +1169,7 @@ jobs: reporter-integration-tests: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: yarn build-for-tests working_directory: packages/reporter @@ -1084,8 +1192,7 @@ jobs: ui-components-integration-tests: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: command: yarn build-for-tests working_directory: packages/ui-components @@ -1105,8 +1212,7 @@ jobs: npm-webpack-preprocessor: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Build command: yarn workspace @cypress/webpack-preprocessor build @@ -1143,16 +1249,14 @@ jobs: npm-webpack-dev-server: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Run tests command: yarn workspace @cypress/webpack-dev-server test npm-vite-dev-server: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Run tests command: yarn test --reporter cypress-circleci-reporter --reporter-options resultsDir=./test_results @@ -1166,8 +1270,7 @@ jobs: npm-rollup-dev-server: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Run tests command: yarn workspace @cypress/rollup-dev-server test @@ -1175,8 +1278,7 @@ jobs: npm-webpack-batteries-included-preprocessor: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Run tests command: yarn workspace @cypress/webpack-batteries-included-preprocessor test @@ -1185,8 +1287,7 @@ jobs: <<: *defaults parallelism: 3 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Build command: yarn workspace @cypress/vue build @@ -1203,15 +1304,14 @@ jobs: npm-design-system: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Build command: yarn workspace @cypress/design-system build - - run: - name: Run tests - command: yarn test - working_directory: npm/design-system + # - run: + # name: Run tests + # command: yarn test + # working_directory: npm/design-system - store-npm-logs @@ -1219,8 +1319,7 @@ jobs: <<: *defaults parallelism: 8 steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - restore_cache: name: Restore yarn cache key: v{{ .Environment.CACHE_VERSION }}-{{ arch }}-npm-react-babel-cache @@ -1240,8 +1339,7 @@ jobs: npm-mount-utils: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Build command: yarn workspace @cypress/mount-utils build @@ -1250,8 +1348,7 @@ jobs: npm-create-cypress-tests: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: yarn workspace create-cypress-tests build - run: name: Run unit test @@ -1260,8 +1357,7 @@ jobs: npm-eslint-plugin-dev: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Run tests command: yarn workspace @cypress/eslint-plugin-dev test @@ -1269,8 +1365,7 @@ jobs: npm-release: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: name: Release packages after all jobs pass command: yarn npm-release @@ -1279,8 +1374,7 @@ jobs: <<: *defaults shell: /bin/bash --login steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace - build-binary - build-npm-package - run: @@ -1362,8 +1456,7 @@ jobs: <<: *defaults steps: # needs uploaded NPM and test binary - - attach_workspace: - at: ~/ + - restore_cached_workspace - run: ls -la # make sure JSON files with uploaded urls are present - run: ls -la binary-url.json npm-package-url.json @@ -1398,8 +1491,7 @@ jobs: "test-npm-module-and-verify-binary": <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_cached_workspace # make sure we have cypress.zip received - run: ls -l - run: ls -l cypress.zip cypress.tgz @@ -1437,14 +1529,8 @@ jobs: docker: - image: cypress/base:12.0.0-libgbm steps: - - attach_workspace: - at: ~/ - # make sure we have cypress.zip received - - run: ls -l - - run: ls -l cypress.zip cypress.tgz + - restore_workspace_binaries - run: mkdir test-binary - - run: node --version - - run: npm --version - run: name: Create new NPM package working_directory: test-binary @@ -1478,14 +1564,8 @@ jobs: default: /root/test-cypress-and-jest <<: *defaults steps: - - attach_workspace: - at: ~/ - # make sure we have cypress.zip received - - run: ls -l - - run: ls -l cypress.zip cypress.tgz + - restore_workspace_binaries - run: mkdir <> - - run: node --version - - run: npm --version - run: name: Create new NPM package ⚗️ working_directory: <> @@ -1521,14 +1601,8 @@ jobs: default: /root/test-scaffold <<: *defaults steps: - - attach_workspace: - at: ~/ - # make sure we have cypress.zip received - - run: ls -l - - run: ls -l cypress.zip cypress.tgz + - restore_workspace_binaries - run: mkdir <> - - run: node --version - - run: npm --version - run: name: Create new NPM package ⚗️ working_directory: <> @@ -1564,14 +1638,8 @@ jobs: default: /root/test-full-typescript <<: *defaults steps: - - attach_workspace: - at: ~/ - # make sure we have cypress.zip received - - run: ls -l - - run: ls -l cypress.zip cypress.tgz + - restore_workspace_binaries - run: mkdir <> - - run: node --version - - run: npm --version - run: name: Create new NPM package ⚗️ working_directory: <> @@ -1596,11 +1664,7 @@ jobs: "test-binary-against-staging": <<: *defaults steps: - - attach_workspace: - at: ~/ - - run: ls -l - # make sure we have the binary and NPM package - - run: ls -l cypress.zip cypress.tgz + - restore_workspace_binaries - clone-repo-and-checkout-release-branch: repo: cypress-test-tiny - run: @@ -1687,15 +1751,16 @@ jobs: - test-binary-against-repo: repo: cypress-documentation browser: firefox - command: "npm run cypress:run" - wait-on: http://localhost:2222 + command: "yarn cypress run" + wait-on: http://localhost:3000 + server-start-command: yarn serve:dist - "test-binary-against-realworld-firefox": + "test-binary-against-conduit-chrome": <<: *defaults steps: - test-binary-against-repo: - repo: cypress-example-realworld - browser: firefox + repo: cypress-example-conduit-app + browser: chrome command: "npm run cypress:run" "test-binary-against-api-testing-firefox": @@ -1726,8 +1791,7 @@ jobs: test-binary-as-specific-user: <<: *defaults steps: - - attach_workspace: - at: ~/ + - restore_workspace_binaries # the user should be "node" - run: whoami - run: pwd @@ -1773,7 +1837,11 @@ jobs: linux-workflow: &linux-workflow jobs: - - build + - node_modules_install: + arch: 'linux' + - build: + requires: + - node_modules_install - lint: name: Linux lint requires: @@ -1887,7 +1955,7 @@ linux-workflow: &linux-workflow # Any attempts to automate this are welcome # If CircleCI provided an "after all" hook, then this wouldn't be necessary - npm-release: - requires: + requires: - build - npm-eslint-plugin-dev - npm-create-cypress-tests @@ -2011,22 +2079,22 @@ linux-workflow: &linux-workflow requires: - create-build-artifacts - - test-binary-against-recipes-firefox: - <<: *testBinaryFirefox - - test-binary-against-kitchensink-firefox: - <<: *testBinaryFirefox - test-binary-against-kitchensink-chrome: - <<: *testBinaryFirefox + <<: *onlyMainBranches + - test-binary-against-conduit-chrome: + <<: *onlyMainBranches + - test-binary-against-recipes-firefox: + <<: *onlyMainBranches + - test-binary-against-kitchensink-firefox: + <<: *onlyMainBranches - test-binary-against-todomvc-firefox: - <<: *testBinaryFirefox + <<: *onlyMainBranches - test-binary-against-documentation-firefox: - <<: *testBinaryFirefox + <<: *onlyMainBranches - test-binary-against-api-testing-firefox: - <<: *testBinaryFirefox - - test-binary-against-realworld-firefox: - <<: *testBinaryFirefox + <<: *onlyMainBranches - test-binary-against-piechopper-firefox: - <<: *testBinaryFirefox + <<: *onlyMainBranches - test-binary-against-cypress-realworld-app: executor: cy-doc-plus filters: @@ -2049,9 +2117,18 @@ linux-workflow: &linux-workflow mac-workflow: &mac-workflow jobs: + - node_modules_install: + name: darwin-node-modules-install + executor: mac + arch: darwin + is-mac: true + <<: *macBuildFilters + - build: name: darwin-build executor: mac + requires: + - darwin-node-modules-install <<: *macBuildFilters - lint: diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index e7b8bf0b69..37251e64d3 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -392,7 +392,7 @@ declare namespace Cypress { Cookies: { debug(enabled: boolean, options?: Partial): void preserveOnce(...names: string[]): void - defaults(options: Partial): void + defaults(options: Partial): CookieDefaults } /** @@ -498,6 +498,14 @@ declare namespace Cypress { defaults(options: Partial): void } + /** + * @see https://on.cypress.io/selector-playground-api + */ + SelectorPlayground: { + defaults(options: Partial): void + getSelector($el: JQuery): JQuery.Selector + } + /** * These events come from Cypress as it issues commands and reacts to their state. These are all useful to listen to for debugging purposes. * @see https://on.cypress.io/catalog-of-events#App-Events @@ -2882,6 +2890,11 @@ declare namespace Cypress { screenshotOnRunFailure: boolean } + interface SelectorPlaygroundDefaultsOptions { + selectorPriority: string[] + onElement: ($el: JQuery) => string | null | undefined + } + interface ScrollToOptions extends Loggable, Timeoutable { /** * Scrolls over the duration (in ms) @@ -5458,6 +5471,8 @@ declare namespace Cypress { interface LogConfig extends Timeoutable { /** The JQuery element for the command. This will highlight the command in the main window when debugging */ $el: JQuery + /** The scope of the log entry. If child, will appear nested below parents, prefixed with '-' */ + type: 'parent' | 'child' /** Allows the name of the command to be overwritten */ name: string /** Override *name* for display purposes only */ diff --git a/npm/create-cypress-tests/README.md b/npm/create-cypress-tests/README.md index 5b71f707ac..98794facd7 100644 --- a/npm/create-cypress-tests/README.md +++ b/npm/create-cypress-tests/README.md @@ -6,7 +6,7 @@ Installs and injects all the required configuration to run cypress tests. ``` cd my-app -npx create-cypress-test +npx create-cypress-tests npx cypress open ``` diff --git a/npm/design-system/.eslintrc.json b/npm/design-system/.eslintrc.json index 3becfaaad2..e41b32b2a8 100644 --- a/npm/design-system/.eslintrc.json +++ b/npm/design-system/.eslintrc.json @@ -16,6 +16,13 @@ "cypress/globals": true }, "rules": { + "no-duplicate-imports": "off", + "no-else-return": [ + "error", + { + "allowElseIf": true + } + ], "react/display-name": "off", "react/function-component-definition": [ "error", diff --git a/npm/design-system/.storybook/main.js b/npm/design-system/.storybook/main.js new file mode 100644 index 0000000000..1a3b02e4d0 --- /dev/null +++ b/npm/design-system/.storybook/main.js @@ -0,0 +1,63 @@ +const cssFolders = require('../css.folders') +const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin') + +module.exports = { + stories: [ + '../src/**/*.stories.mdx', + '../src/**/*.stories.@(js|jsx|ts|tsx)', + ], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + ], + webpackFinal: async (config) => { + const sassLoader = { + loader: 'sass-loader', + options: { + sassOptions: { + includePaths: cssFolders, + }, + }, + } + + config.module.rules.push(...[ + { + test: /\.s[ca]ss$/, + exclude: /\.module\.s[ca]ss$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: { + compileType: 'icss', + }, + }, + }, + sassLoader, + ], + }, + { + test: /\.module\.s[ca]ss$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: { + compileType: 'module', + localIdentName: '[name]__[local]--[hash:base64:5]', + exportLocalsConvention: 'camelCaseOnly', + }, + }, + }, + sassLoader, + ], + }, + ]) + + config.resolve.plugins.push(new TsconfigPathsPlugin()) + + return config + }, +} diff --git a/npm/design-system/.storybook/preview.js b/npm/design-system/.storybook/preview.js new file mode 100644 index 0000000000..aa5466ee2f --- /dev/null +++ b/npm/design-system/.storybook/preview.js @@ -0,0 +1,5 @@ +import '../src/global.scss' + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, +} diff --git a/npm/design-system/README.md b/npm/design-system/README.md index c5b828a066..6868697c46 100644 --- a/npm/design-system/README.md +++ b/npm/design-system/README.md @@ -35,10 +35,10 @@ SCSS usage: ```scss // scoped within the *.scss file -@use '@cypress/design-system/src/index.scss' as *; +@use '@cypress/design-system' as *; // import variables and mixins throughout the whole project -// or @import('@cypress/design-system/src/index.scss'); +// or @import('@cypress/design-system'); .my-component { text-color: $accent-color-01; diff --git a/npm/design-system/css.folders.js b/npm/design-system/css.folders.js new file mode 100644 index 0000000000..9e771386c2 --- /dev/null +++ b/npm/design-system/css.folders.js @@ -0,0 +1,4 @@ +const path = require('path') + +// Resolve css dir and both local and monorepo node_modules +module.exports = ['src/css', 'node_modules', '../../node_modules'].map((p) => path.resolve(p)) diff --git a/npm/design-system/cypress/support/index.js b/npm/design-system/cypress/support/index.js index 72b4e3f257..2996795fea 100644 --- a/npm/design-system/cypress/support/index.js +++ b/npm/design-system/cypress/support/index.js @@ -1,2 +1,11 @@ import 'regenerator-runtime/runtime' import 'cypress-real-events/support' + +// Need to register these once per app. Depending which components are consumed +// from @cypress/design-system, different icons are required. +import { library } from '@fortawesome/fontawesome-svg-core' +import { fab } from '@fortawesome/free-brands-svg-icons' +import { fas } from '@fortawesome/free-solid-svg-icons' + +library.add(fas) +library.add(fab) diff --git a/npm/design-system/package.json b/npm/design-system/package.json index 7a4884c10f..b063c06d9e 100644 --- a/npm/design-system/package.json +++ b/npm/design-system/package.json @@ -6,10 +6,13 @@ "scripts": { "build": "rimraf dist && yarn rollup -c rollup.config.js", "build-prod": "yarn build", + "build-storybook": "build-storybook", + "build-style-types": "tsm \"src/css/derived/*.scss\" --nameFormat none --exportType default", "cy:open": "node ../../scripts/cypress.js open-ct --project ${PWD}", "cy:open:debug": "node --inspect-brk ../../scripts/start.js --component-testing --project ${PWD}", "cy:run": "node ../../scripts/cypress.js run-ct --project ${PWD}", "cy:run:debug": "node --inspect-brk ../../scripts/start.js --component-testing --run-project ${PWD}", + "storybook": "start-storybook -p 6006", "pretest": "yarn transpile", "test": "yarn cy:run", "transpile": "tsc", @@ -22,7 +25,8 @@ "@fortawesome/free-solid-svg-icons": "5.15.2", "@fortawesome/react-fontawesome": "0.1.14", "classnames": "2.2.6", - "debug": "4.3.2" + "debug": "4.3.2", + "react-aria": "^3.5.0" }, "devDependencies": { "@babel/core": "7.4.5", @@ -33,29 +37,39 @@ "@cypress/react": "0.0.0-development", "@cypress/vite-dev-server": "0.0.0-development", "@percy/cypress": "2.3.2", + "@react-types/button": "^3.3.1", + "@react-types/shared": "^3.5.0", "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-image": "2.0.6", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.1.1", + "@rollup/plugin-typescript": "^8.2.1", + "@storybook/addon-actions": "^6.1.21", + "@storybook/addon-essentials": "^6.1.21", + "@storybook/addon-links": "^6.1.21", + "@storybook/preset-typescript": "^3.0.0", + "@storybook/react": "^6.1.21", "@types/node": "14.14.31", "@types/semver": "7.3.4", "babel-loader": "8.0.6", - "css-loader": "2.1.1", + "css-loader": "^5.1.3", "cypress": "0.0.0-development", "cypress-real-events": "1.1.0", "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", + "postcss": "^8.2.8", "react": "16.8.6", "react-dom": "16.8.6", "rollup": "^2.38.5", - "rollup-plugin-copy-assets": "2.0.3", + "rollup-plugin-copy": "^3.4.0", "rollup-plugin-peer-deps-external": "2.2.4", - "rollup-plugin-postcss-modules": "2.0.2", - "rollup-plugin-typescript2": "^0.29.0", + "rollup-plugin-postcss": "^4.0.0", "sass": "1.32.8", "sass-loader": "10.1.1", - "style-loader": "0.23.1", + "style-loader": "^2.0.0", "svg-url-loader": "3.0.3", + "tsconfig-paths-webpack-plugin": "^3.5.1", + "typed-scss-modules": "^4.1.1", "typescript": "^4.2.3", "vite": "2.1.3", "webpack": "4.44.1" @@ -74,7 +88,7 @@ "type": "git", "url": "https://github.com/cypress-io/cypress.git" }, - "author": "Jessica Sachs ", + "author": "Cypress.io", "keywords": [ "design-system", "cypress", @@ -82,6 +96,7 @@ ], "unpkg": "dist/cypress-design-system.browser.js", "module": "dist/cypress-design-system.esm-bundler.js", + "style": "dist/index.scss", "publishConfig": { "access": "restricted" }, @@ -92,4 +107,4 @@ "expect" ] } -} \ No newline at end of file +} diff --git a/npm/design-system/rollup.config.js b/npm/design-system/rollup.config.js index 88057a4a6a..5ef27c6d5a 100644 --- a/npm/design-system/rollup.config.js +++ b/npm/design-system/rollup.config.js @@ -1,12 +1,14 @@ -import ts from 'rollup-plugin-typescript2' +import ts from '@rollup/plugin-typescript' import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' import peerDepsExternal from 'rollup-plugin-peer-deps-external' -import postcss from 'rollup-plugin-postcss-modules' +import postcss from 'rollup-plugin-postcss' import pkg from './package.json' import image from '@rollup/plugin-image' -import copy from 'rollup-plugin-copy-assets' +import copy from 'rollup-plugin-copy' + +const cssFolders = require('./css.folders') const banner = ` /** @@ -31,14 +33,42 @@ function createEntry (options) { ], plugins: [ peerDepsExternal(), + ts({ + // check: format === 'es' && isBrowser, + declaration: format === 'es', + target: 'es5', // not sure what this should be? + module: format === 'cjs' ? 'es2015' : 'esnext', + }), resolve(), json(), commonjs(), - postcss({ writeDefinitions: false }), + postcss({ + modules: { + globalModulePaths: ['src/css/derived/export.scss'], + }, + use: [ + ['sass', { + includePaths: cssFolders, + }], + ], + }), image(), copy({ - assets: [ - './index.scss', + targets: [ + // Purposefully ignore global.scss to prevent direct imports + { + src: './src/index.scss', + dest: './dist', + }, + // Purposefully ignore `derived` directory SASS + { + src: './src/css/*.scss', + dest: './dist/css', + }, + { + src: './src/css/derived/*.d.ts', + dest: './dist/css/derived', + }, ], }), ], @@ -68,19 +98,6 @@ function createEntry (options) { /* eslint-disable no-console */ console.log(`Building ${format}: ${config.output.file}`) - config.plugins.push( - ts({ - check: format === 'es' && isBrowser, - tsconfigOverride: { - compilerOptions: { - declaration: format === 'es', - target: 'es5', // not sure what this should be? - module: format === 'cjs' ? 'es2015' : 'esnext', - }, - }, - }), - ) - return config } diff --git a/npm/design-system/src/components/Button/Button.module.scss b/npm/design-system/src/components/Button/Button.module.scss deleted file mode 100644 index 613f737cfd..0000000000 --- a/npm/design-system/src/components/Button/Button.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -@use '../../index.scss' as *; - -.button { - background: $accent-01; - - &:hover { - @extend .text-black; - background: $papaya-05; - } -} diff --git a/npm/design-system/src/components/Button/Button.spec.tsx b/npm/design-system/src/components/Button/Button.spec.tsx deleted file mode 100644 index df728b48c9..0000000000 --- a/npm/design-system/src/components/Button/Button.spec.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import { mount } from '@cypress/react' -import { Button } from './Button' - -describe('Button', () => { - it('renders', () => { - mount( -) diff --git a/npm/design-system/src/components/Button/index.ts b/npm/design-system/src/components/Button/index.ts deleted file mode 100644 index 8486fd6d62..0000000000 --- a/npm/design-system/src/components/Button/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Button' diff --git a/npm/design-system/src/components/Nav/LeftNav.module.scss b/npm/design-system/src/components/Nav/LeftNav.module.scss index ed6c02597d..290d6ff71d 100644 --- a/npm/design-system/src/components/Nav/LeftNav.module.scss +++ b/npm/design-system/src/components/Nav/LeftNav.module.scss @@ -1,4 +1,6 @@ -@use '../../index.scss' as *; +@use 'baseColors' as *; +@use 'semanticColors' as *; +@use 'typography' as *; $left-nav-width: 48px; $icon-color: $metal-20; @@ -33,7 +35,7 @@ $active-color: $brand-01; .item { height: 100%; - font-size: $text-ml; + font-size: text(ml); cursor: pointer; color: $icon-color; diff --git a/npm/design-system/src/components/Playground.spec.tsx b/npm/design-system/src/components/Playground.spec.tsx index 42fa7c8fd2..566b951b4f 100644 --- a/npm/design-system/src/components/Playground.spec.tsx +++ b/npm/design-system/src/components/Playground.spec.tsx @@ -1,6 +1,5 @@ import React from 'react' import { CypressLogo } from './CypressLogo/CypressLogo' -import { SearchInput } from './SearchInput/SearchInput' import { mount } from '@cypress/react' import { library } from '@fortawesome/fontawesome-svg-core' @@ -22,52 +21,4 @@ describe('Playground', () => { , ) }) - - it('search input', () => { - const Wrapper = (props) => { - const [value, setValue] = React.useState(props.value || '') - const inputRef = React.useRef(null) - - return ( - { - setValue('') - inputRef.current.focus() - }} - onChange={(event) => setValue(event.target.value)} - > - - ) - } - - mount( - <> - -
- {/* */} -
- {/* */} - , - ) - - cy.get('input').should('exist') - cy.get('input').should('exist').first() - // .its('placeholder').should('be', placeholderText) - // .click() - - cy.get('input').first().type('Hello World!').clear().type('WHATS UP ⚡️') - cy.get('input').first().should('contain.value', '⚡️') - - cy.get('input') - .click() - .type('hello') - .get('[data-testid=close]') - .click() - .get('input') - .should('be.focused') - }) }) diff --git a/npm/design-system/src/components/SearchInput/SearchInput.module.scss b/npm/design-system/src/components/SearchInput/SearchInput.module.scss deleted file mode 100644 index 71e9cc7814..0000000000 --- a/npm/design-system/src/components/SearchInput/SearchInput.module.scss +++ /dev/null @@ -1,62 +0,0 @@ -@use '../../index.scss' as *; - -$active-color: $metal-50; -$inactive-color: $metal-20; -$input-color: $metal-50; - -@mixin quick-transition($param) { - transition: $param 0.15s ease-in-out; -} - -.searchInput { - @include text-style-light-xs; - @include quick-transition(border); - color: $input-color; - padding: 0 $text-m 0 $text-xxs; - width: 100%; - border: unset; - outline: unset; - border-bottom: 1px solid $inactive-color; - - &.hasPrefix { - padding: 0.2rem $text-m; - } -} - -// prefix and suffix math - -$icon-size: $text-xs; -$icon-negative-margin: calc(#{$icon-size} * (-4 / 3)); -$icon-margin: calc(#{$icon-size} / 3); - -.prefix, .suffix { - @include quick-transition(color); - position: absolute; - font-size: $icon-size; - color: $inactive-color; -} - -.prefix { - margin: $icon-margin; -} - -.suffix { - margin: $icon-margin $icon-negative-margin; - right: 20px; -} - -.inputButton { - position: relative; - display: flex; - margin: 1rem; - align-items: center; - - &:active, &:focus-within { - .searchInput { - border-bottom: 1px solid $active-color; - } - .prefix { - color: $active-color; - } - } -} diff --git a/npm/design-system/src/components/SearchInput/SearchInput.tsx b/npm/design-system/src/components/SearchInput/SearchInput.tsx deleted file mode 100644 index 22e9b55237..0000000000 --- a/npm/design-system/src/components/SearchInput/SearchInput.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import { FontAwesomeIcon, FontAwesomeIconProps } from '@fortawesome/react-fontawesome' -import cs from 'classnames' -import styles from './SearchInput.module.scss' - -interface SearchInputProps extends React.InputHTMLAttributes { - prefixIcon?: FontAwesomeIconProps['icon'] - placeholder: string - onSuffixClicked?: () => void - onPrefixClicked?: () => void - inputRef: React.RefObject -} - -export const SearchInput: React.FC = (props) => { - const { onSuffixClicked } = props - - const prefixIcon = props.prefixIcon && ( - - ) - - const onKeyPress = React.useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - onSuffixClicked?.() - } - }, [onSuffixClicked]) - - return ( - - {prefixIcon} - - { - props.value && ( - - ) - } - - ) -} diff --git a/npm/design-system/src/components/searchInput/SearchInput.spec.tsx b/npm/design-system/src/components/searchInput/SearchInput.spec.tsx new file mode 100644 index 0000000000..c5fd916304 --- /dev/null +++ b/npm/design-system/src/components/searchInput/SearchInput.spec.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' +import { mount } from '@cypress/react' + +import { SearchInput } from './SearchInput' +import { useCallback, useState } from 'react' + +describe('SearchInput', () => { + const StatefulWrapper: React.FC<{onInput?: (input: string) => void}> = ({ onInput }) => { + const [value, setValue] = useState('') + + const memoedOnInput = useCallback((input: string) => { + setValue(input) + onInput?.(input) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return + } + + it('should render', () => { + mount( {}} />) + cy.get('input').should('exist') + }) + + it('should pass input to onInput', () => { + const onInput = cy.stub() + + mount() + + const string = 'Testing input!' + + cy.get('input').type(string).then(() => { + expect(onInput).to.be.callCount(string.length) + + for (let i = 0; i < string.length; i++) { + expect(onInput.getCall(i)).to.be.calledWithExactly(string.slice(0, i + 1)) + } + }) + }) + + describe('Clear button', () => { + it('should only show when text is present', () => { + mount() + + cy.get('[aria-label="Clear search"]').should('not.exist') + + cy.get('input').type('some input') + + cy.get('[aria-label="Clear search"]').should('exist') + }) + + it('should clear input on click', () => { + const onInput = cy.stub() + + mount() + + cy.get('input').should('have.value', 'a value') + + cy.get('[aria-label="Clear search"]').click().then(() => expect(onInput).to.be.calledOnceWith('')) + }) + + it('should focus input on click', () => { + mount( {}} />) + + cy.get('[aria-label="Clear search"]').click() + + cy.get('input').should('be.focused') + }) + }) +}) diff --git a/npm/design-system/src/components/searchInput/SearchInput.stories.tsx b/npm/design-system/src/components/searchInput/SearchInput.stories.tsx new file mode 100644 index 0000000000..0325bb8771 --- /dev/null +++ b/npm/design-system/src/components/searchInput/SearchInput.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' + +import { createStory, createStorybookConfig } from 'stories/util' + +import { SearchInput as SearchInputComponent } from './SearchInput' +import { useState } from 'react' + +export default createStorybookConfig({ + title: 'Components/SearchInput', +}) + +export const SearchInput = createStory(() => { + const [value, setValue] = useState('') + + return ( +
+ +
+ ) +}) diff --git a/npm/design-system/src/components/searchInput/SearchInput.tsx b/npm/design-system/src/components/searchInput/SearchInput.tsx new file mode 100644 index 0000000000..f1b1f7c16a --- /dev/null +++ b/npm/design-system/src/components/searchInput/SearchInput.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { FormEvent, MutableRefObject, useCallback } from 'react' +import { IconInput, IconSettings } from 'core/input/IconInput' +import { CoreComponent } from 'core/shared' +import { TextSize } from 'css' +import { useCombinedRefs } from '../../hooks/useCombinedRefs' + +export interface SearchInputProps extends CoreComponent { + inputRef?: MutableRefObject | null + + value: string + placeholder: string + + /** + * Defaults to 'm' + */ + size?: TextSize + + onInput: (input: string) => void + + ['aria-label']: string +} + +const prefixItem: IconSettings = { + icon: 'search', +} + +export const SearchInput: React.FC = ({ inputRef = null, onInput: externalOnInput, ...props }) => { + const ref = React.useRef(null) + + useCombinedRefs(ref, inputRef) + + const onInput = useCallback((e: FormEvent) => externalOnInput(e.currentTarget.value), [externalOnInput]) + const onClear = useCallback(() => { + externalOnInput('') + + ref.current?.focus() + }, [externalOnInput]) + + return ( + 0 ? { icon: 'times', onPress: onClear, 'aria-label': 'Clear search' } : undefined} + onInput={onInput} + /> + ) +} diff --git a/npm/design-system/src/core/button/Button.module.scss b/npm/design-system/src/core/button/Button.module.scss new file mode 100644 index 0000000000..fa5803d922 --- /dev/null +++ b/npm/design-system/src/core/button/Button.module.scss @@ -0,0 +1,69 @@ +@use 'semanticColors' as *; +@use 'typography' as *; +@use 'spacing' as *; +@use 'surfaces' as *; +@use 'func' as *; +@use 'util'; + +$button-vertical-padding: change-rem-unit-to-em(spacing(xs)); +$button-horizontal-padding: change-rem-unit-to-em(spacing(s)); + +.button { + position: relative; + display: inline-block; + // Reset + @include util.no-selection; + border: 0; + text-decoration: none; + cursor: pointer; + + // Style + padding: $button-vertical-padding $button-horizontal-padding; + border-radius: $button-radius; + + color: $control-text-color-white; + background-color: $button-blue-color; + + &:hover { + background-color: $button-blue-hover-color; + } + + &:active { + background-color: $button-blue-push-color; + } + + &:focus { + outline: none; + } + + &.white { + color: $control-text-color-black; + background-color: $button-white-color; + + border: 1px solid $control-border-color; + + &:hover { + background-color: $button-white-hover-color; + } + + &:active { + background-color: $button-white-push-color; + } + } + + &.disableBorder { + border: 0; + } +} + +:global { + :local(.button.white:not(.disableBorder)) { + &.focused::after { + // White buttons have a border that needs to be compensated for + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; + } + } +} diff --git a/npm/design-system/src/core/button/Button.stories.tsx b/npm/design-system/src/core/button/Button.stories.tsx new file mode 100644 index 0000000000..56e932eaaf --- /dev/null +++ b/npm/design-system/src/core/button/Button.stories.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { action } from '@storybook/addon-actions' + +import { createStory, createStorybookConfig } from 'stories/util' + +import { Button as ButtonComponent, LinkButton } from './Button' +import { IconButton as IconButtonComponent } from './IconButton' + +import typography from 'css/derived/jsTypography.scss' +import { TextSize } from 'css' +import { PaddedBox } from '../surface/paddedBox/PaddedBox' +import { Icon } from '../icon/Icon' + +export default createStorybookConfig({ + title: 'Core/Button', +}) + +export const Button = createStory(() => ( +
+ + Simple button + Anchor button + + + Simple button + Anchor button + + + Simple button + Anchor button + +
+)) + +export const ButtonSizes = createStory(() => ( +
+
+ {Object.keys(typography).filter((key) => key !== 'type' && !key.startsWith('line-height') && !key.startsWith('text-mono')).map((key) => { + const size = key.replace('text-', '') + + return ( + + {`Button ${size}`} + + ) + })} +
+
+)) + +export const IconButton = createStory(() => ( +
+
+ +
+ + + Text button + + + {' Inline Icon with text'} + + + + + Text button + + + {' Inline Icon with text'} + + +
+)) diff --git a/npm/design-system/src/core/button/Button.tsx b/npm/design-system/src/core/button/Button.tsx new file mode 100644 index 0000000000..701f6be47c --- /dev/null +++ b/npm/design-system/src/core/button/Button.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { useRef, RefObject } from 'react' +import cs from 'classnames' + +import { useButton } from '@react-aria/button' +import { AriaButtonProps } from '@react-types/button' +import { TextSizableComponent } from '../shared' +import { styledTextSizeClassNames } from '../text/StyledText' + +import styles from './Button.module.scss' +import { FocusRing } from '@react-aria/focus' +import { focusClass } from 'css/derived/util' + +interface SharedButtonProps extends TextSizableComponent { + /** + * Defaults to 'blue' + */ + color?: 'blue' | 'white' + noBorder?: boolean + + ['aria-label']: string +} + +export type BaseButtonProps = SharedButtonProps & (({ + elementType: 'button' +} & AriaButtonProps<'button'>) | ({ + elementType: 'a' +} & AriaButtonProps<'a'>)) + +export type ButtonProps = SharedButtonProps & Omit, 'elementType'> + +export type LinkButtonProps = ButtonProps & Omit, 'elementType'> + +export const BaseButton: React.FC = ({ size, color, noBorder, children, ...props }) => { + const buttonRef = useRef(null) + + const { buttonProps } = useButton(props, buttonRef) + + const textClass = styledTextSizeClassNames(size) + + const classNames = cs(textClass, styles.button, { + [styles.white]: color === 'white', + [styles.disableBorder]: noBorder, + }, buttonProps.className, props.className) + + return ( + + {props.elementType === 'button' ? ( + + ) : ( + } className={classNames}> + {children} + + )} + + ) +} + +export const Button: React.FC, 'elementType'>> = (props) => + +export const LinkButton: React.FC, 'elementType'>> = (props) => diff --git a/npm/design-system/src/core/button/IconButton.tsx b/npm/design-system/src/core/button/IconButton.tsx new file mode 100644 index 0000000000..69923c9bf8 --- /dev/null +++ b/npm/design-system/src/core/button/IconButton.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import { Icon, IconProps } from '../icon/Icon' +import { BaseButton, BaseButtonProps } from './Button' + +export type IconButtonProps = { + iconClassName?: string +} & BaseButtonProps & IconProps; + +// We don't actually need to spread several of these props, but FontAwesome complains if it receives extra props +export const IconButton: React.FC = ({ className, iconClassName, color, elementType, noBorder, onPress, ...props }) => ( + // Cast to button just to prevent TS error + + + +) diff --git a/npm/design-system/src/core/icon/Icon.module.scss b/npm/design-system/src/core/icon/Icon.module.scss new file mode 100644 index 0000000000..405471f454 --- /dev/null +++ b/npm/design-system/src/core/icon/Icon.module.scss @@ -0,0 +1,23 @@ +$icon-margin: 0.15em; +$icon-bottom-offset: 0.125em; + +$icon-size: 1em - $icon-margin * 2; + +:global(.svg-inline--fa) { + // TODO: Is there ever a need for the icon to not take a square space as long as it's properly centered? + &.icon { + position: relative; + + width: $icon-size; + height: $icon-size; + + bottom: $icon-bottom-offset; + } + + &.ignoreTextCenter { + width: 1em; + height: 1em; + + bottom: auto; + } +} \ No newline at end of file diff --git a/npm/design-system/src/core/icon/Icon.stories.module.scss b/npm/design-system/src/core/icon/Icon.stories.module.scss new file mode 100644 index 0000000000..a1dcac37bc --- /dev/null +++ b/npm/design-system/src/core/icon/Icon.stories.module.scss @@ -0,0 +1,9 @@ +.icon { + background-color: orange; + + border: 1px solid black; +} + +.textIcon { + border: 1px solid black; +} diff --git a/npm/design-system/src/core/icon/Icon.stories.tsx b/npm/design-system/src/core/icon/Icon.stories.tsx new file mode 100644 index 0000000000..9ccee87756 --- /dev/null +++ b/npm/design-system/src/core/icon/Icon.stories.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { Story } from '@storybook/react' + +import { createStory, createStorybookConfig } from 'stories/util' + +import { Icon as IconComponent } from './Icon' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { fab } from '@fortawesome/free-brands-svg-icons' +import { fas } from '@fortawesome/free-solid-svg-icons' + +import typography from 'css/derived/jsTypography.scss' +import styles from './Icon.stories.module.scss' +import { TextSize } from 'css' +import { Baseline } from '../../measure/baseline/Baseline' + +library.add(fas) +library.add(fab) + +const fontOptions = ['-apple-system, BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica'] + +export default createStorybookConfig({ + title: 'Core/Icon', + argTypes: { + font: { + control: { + type: 'select', + options: fontOptions, + }, + }, + }, +}) + +const Template: Story<{ + font: string +}> = ({ font }) => ( +
+ + + + +
+ {Object.keys(typography).filter((key) => key !== 'type').map((key) => { + const size = key.replace('text-', '') + + return ( +
+
+ {size} +
+ + + + The five boxing wizards jump quickly + + + +
+ ) + })} +
+) + +export const Icon = createStory(Template, { + font: fontOptions[0], +}) diff --git a/npm/design-system/src/core/icon/Icon.tsx b/npm/design-system/src/core/icon/Icon.tsx new file mode 100644 index 0000000000..a248603148 --- /dev/null +++ b/npm/design-system/src/core/icon/Icon.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import { SVGAttributes } from 'react' +import cs from 'classnames' + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { IconName } from '@fortawesome/fontawesome-svg-core' +import { styledTextSizeClassNames } from '../text/StyledText' + +import styles from './Icon.module.scss' +import { TextSizableComponent } from '../shared' + +export interface IconProps extends TextSizableComponent, Omit, 'mask'> { + // TODO: Limit literals to only those available in the iconset + icon: IconName + + ignoreTextCenter?: boolean +} + +// Currently only a passthrough for FontAwesome. This provides a single place to swap out the icon library +export const Icon: React.FC = ({ className, size, lineHeight, icon, ignoreTextCenter, ...props }) => ( + +) diff --git a/npm/design-system/src/core/input/IconInput.module.scss b/npm/design-system/src/core/input/IconInput.module.scss new file mode 100644 index 0000000000..7451238edd --- /dev/null +++ b/npm/design-system/src/core/input/IconInput.module.scss @@ -0,0 +1,98 @@ +@use 'spacing' as *; +@use 'semanticColors' as *; +@use '../icon/Icon.module.scss' as *; +@use 'surfaces' as *; + +$icon-overall-size: 1.5em; +$input-icon-margin: 0.25em; + +.iconInput { + position: relative; + display: flex; + align-items: center; + + // No actual border drawn. This provides the mask for the rounded corners to clip child elements + border-radius: $button-radius; + overflow: hidden; + + z-index: 0; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + border: 1px solid $control-border-color; + border-radius: $button-radius; + + pointer-events: none; + } + + .wrapper { + display: flex; + flex-grow: 1; + + &:first-child { + // If first child, apply border padding + padding-left: spacing(ms); + } + + &:last-child { + // If last child, apply border padding + padding-right: spacing(ms); + } + } + + .input { + flex-grow: 1; + // Required to allow input to shrink in certain size scenarios + width: 0; + + background-color: transparent; + border: 0; + + &:focus { + outline: none; + } + } + + .icon { + flex-grow: 0; + flex-shrink: 0; + + // Icon + margin: 0 $input-icon-margin; + bottom: 0; + + color: $control-text-color-black; + } + + .iconButton { + // See global expression below + // Make sure button fills entire height, to enable the button focus ring to cover the IconInput border + align-self: stretch; + + padding: 0 $input-icon-margin; + line-height: 1; + + border-radius: 0; + + &::after { + z-index: 1; + } + } +} + +:global { + :local(.iconButton) { + &.focused { + // Set overflow and border radius to clip corners when focused + overflow: hidden; + + border-radius: $button-radius; + } + } +} diff --git a/npm/design-system/src/core/input/IconInput.tsx b/npm/design-system/src/core/input/IconInput.tsx new file mode 100644 index 0000000000..3bc63f4e6f --- /dev/null +++ b/npm/design-system/src/core/input/IconInput.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { InputHTMLAttributes, RefAttributes } from 'react' +import cs from 'classnames' +import { useFocusRing } from '@react-aria/focus' +import { PressEvent } from '@react-types/shared' +import { Icon, IconProps } from '../icon/Icon' +import { BasicInput, InputBase, InputProps, InputRenderer } from './InputBase' + +import { focusClass, modifySize } from 'css/derived/util' +import { textSizeToClassName } from '../text/StyledText' +import { IconButton, IconButtonProps } from '../button/IconButton' + +import styles from './IconInput.module.scss' + +export type IconSettings = { + className?: string + icon: IconProps['icon'] + hideOnFocus?: boolean + hidden?: boolean +} & ({ + // If click is specified, it _must_ have an aria label + onPress: (event: PressEvent) => void + ['aria-label']: string +} | { + onPress?: undefined + ['aria-label']?: undefined +}) + +export type IconInputProps = InputProps<{ + prefixIcon?: IconSettings + suffixIcon?: IconSettings +} & Omit, 'size'>> +& RefAttributes + +export const IconInput: React.FC = (props) => + +const IconInputComponent: InputRenderer = ({ size = 'm', prefixIcon, suffixIcon, className, ...props }, inputProps, inputRef) => { + const iconSize = modifySize(size, 2) + const { isFocused, focusProps } = useFocusRing({ isTextInput: true }) + + const prefixIconProps = prefixIcon ? { + className: cs(prefixIcon.onPress ? styles.iconButton : styles.icon, prefixIcon.className), + size: iconSize, + ['aria-label']: prefixIcon['aria-label'], + } : {} + + const suffixIconProps = suffixIcon ? { + className: cs(suffixIcon.onPress ? styles.iconButton : styles.icon, suffixIcon.className), + size: iconSize, + ['aria-label']: suffixIcon['aria-label'], + } : {} + + return ( + + {prefixIcon && ( + prefixIcon.onPress ? ( + + ) : + )} + {/* Apply iconSize to input wrapper, so we have the same em measure */} +
+ +
+ {suffixIcon && ( + suffixIcon.onPress ? ( + + ) : + )} +
+ ) +} diff --git a/npm/design-system/src/core/input/Input.stories.tsx b/npm/design-system/src/core/input/Input.stories.tsx new file mode 100644 index 0000000000..a3ccce90fb --- /dev/null +++ b/npm/design-system/src/core/input/Input.stories.tsx @@ -0,0 +1,136 @@ +import * as React from 'react' +import { action } from '@storybook/addon-actions' + +import { createStory, createStorybookConfig } from 'stories/util' + +import { Input as InputComponent } from './Input' +import { IconInput as IconInputComponent } from './IconInput' + +import typography from 'css/derived/jsTypography.scss' +import { TextSize } from 'css' + +export default createStorybookConfig({ + title: 'Core/Input', +}) + +export const Input = createStory(() => ( +
+ + +
+)) + +export const Icon = createStory(() => ( +
+
+ +
+
+ +
+
+ + + + + +
+
+)) + +export const IconSizes = createStory(() => ( +
+
+ {Object.keys(typography).filter((key) => key !== 'type' && !key.startsWith('line-height') && !key.startsWith('text-mono') && key !== 'text-3xl' && key !== 'text-4xl').map((key) => { + const size = key.replace('text-', '') + + return ( + + ) + })} +
+
+)) diff --git a/npm/design-system/src/core/input/Input.tsx b/npm/design-system/src/core/input/Input.tsx new file mode 100644 index 0000000000..e927d57c46 --- /dev/null +++ b/npm/design-system/src/core/input/Input.tsx @@ -0,0 +1,4 @@ +import * as React from 'react' +import { basicInputRenderer, InputBase, InputProps } from './InputBase' + +export const Input: React.FC> = (props) => diff --git a/npm/design-system/src/core/input/InputBase.module.scss b/npm/design-system/src/core/input/InputBase.module.scss new file mode 100644 index 0000000000..d06ad5ed1a --- /dev/null +++ b/npm/design-system/src/core/input/InputBase.module.scss @@ -0,0 +1,9 @@ +@use 'semanticColors' as *; +@use 'surfaces' as *; + +input.input { + font-weight: normal; + + border: 1px solid $control-border-color; + border-radius: $button-radius; +} diff --git a/npm/design-system/src/core/input/InputBase.tsx b/npm/design-system/src/core/input/InputBase.tsx new file mode 100644 index 0000000000..5ad8b01740 --- /dev/null +++ b/npm/design-system/src/core/input/InputBase.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import { CSSProperties, InputHTMLAttributes, MutableRefObject, ReactNode, RefObject, TextareaHTMLAttributes, useMemo, useRef } from 'react' +import { useTextField } from 'react-aria' +import cs from 'classnames' + +import { ExtractFirstArg } from 'util/types' +import { LineHeight, TextSize } from 'css' +import { styledTextSizeClassNames } from '../text/StyledText' +import { SizingProps } from 'core/shared' + +import styles from './InputBase.module.scss' +import { useCombinedRefs } from 'hooks/useCombinedRefs' + +export interface SharedInputBaseProps extends SizingProps { + inputRef?: MutableRefObject | null + + label: { + type: 'tag' + contents: ReactNode + labelClassName?: string + size?: TextSize + lineHeight?: LineHeight + } | { + type: 'aria' + contents: string + } + + /** + * If true, render as a textarea (multiline) instead of an input. Defaults to false + */ + textArea?: boolean +} + +export type InputProps = SharedInputBaseProps & { + className?: string + style?: CSSProperties +} & T + +export type InputRenderer = (componentProps: Omit, 'label'>, inputProps: InputHTMLAttributes, inputRef: RefObject) => ReactNode + +export type InputBaseProps = SharedInputBaseProps & { + inputRenderer: InputRenderer +} & T + +export const InputBase = ({ inputRenderer, label, textArea, inputRef: externalInputRef = null, ...props }: InputBaseProps) => { + const inputRef = useRef(null) + + useCombinedRefs(inputRef, externalInputRef) + + const textFieldProps = useMemo((): ExtractFirstArg => { + const newProps: ExtractFirstArg = { + ...props, + inputElementType: textArea ? 'textarea' : 'input', + } + + if (label.type === 'aria') { + newProps['aria-label'] = label.contents + } else if (label.type === 'tag') { + newProps.label = label.contents + } + + return newProps + }, [label, textArea, props]) + + const { inputProps, labelProps } = useTextField(textFieldProps, inputRef) + + return ( + <> + {label.type === 'tag' && ( + + )} + {/* TODO: This cast is incorrect. It can be textarea */} + {inputRenderer(props as Omit, 'label'>, inputProps as InputHTMLAttributes, inputRef)} + + ) +} + +export type BasicInputProps = SizingProps & Omit, 'size'> & { + inputRef: RefObject + + /** + * If true, render as a textarea (multiline) instead of an input. Defaults to false + */ + textArea?: boolean +} + +/** + * **Note:** Should not be directly rendered in app code. This should only be provided in an `inputRenderer` function + */ +export const BasicInput: React.FC = ({ inputRef, className, size, lineHeight, textArea, ...props }) => { + const textClass = styledTextSizeClassNames(size, lineHeight) + + return textArea + ?