diff --git a/.github/workflows/devPush.yml b/.github/workflows/devPush.yml index 9b5c74ce293..04af38112a0 100644 --- a/.github/workflows/devPush.yml +++ b/.github/workflows/devPush.yml @@ -206,7 +206,6 @@ jobs: env: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} @@ -361,7 +360,6 @@ jobs: env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} @@ -436,7 +434,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_SECRET_ACCESS_KEY }} AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ @@ -594,7 +591,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ diff --git a/.github/workflows/nextPush.yml b/.github/workflows/nextPush.yml index 05b3e92154c..dfe0a3519ac 100644 --- a/.github/workflows/nextPush.yml +++ b/.github/workflows/nextPush.yml @@ -206,7 +206,6 @@ jobs: env: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} @@ -361,7 +360,6 @@ jobs: env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} @@ -436,7 +434,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DEPLOY_WEBINY_PROJECT_AWS_SECRET_ACCESS_KEY }} AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ @@ -594,7 +591,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ diff --git a/.github/workflows/pullRequestsCommandCypress.yml b/.github/workflows/pullRequestsCommandCypress.yml index 6bed0d87ca7..3c20155a0a4 100644 --- a/.github/workflows/pullRequestsCommandCypress.yml +++ b/.github/workflows/pullRequestsCommandCypress.yml @@ -44,6 +44,9 @@ jobs: - uses: actions/checkout@v3 + - name: Install Hub Utility + run: sudo apt-get install -y hub + - name: Checkout Pull Request run: hub pr checkout ${{ github.event.issue.number }} env: @@ -71,7 +74,6 @@ jobs: env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} @@ -86,6 +88,9 @@ jobs: with: path: dev + - name: Install Hub Utility + run: sudo apt-get install -y hub + - name: Checkout Pull Request run: hub pr checkout ${{ github.event.issue.number }} working-directory: dev @@ -227,17 +232,23 @@ jobs: env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} WEBINY_PULUMI_BACKEND: ${{ secrets.WEBINY_PULUMI_BACKEND }}${{ needs.e2e-wby-cms-ddb-init.outputs.ts }}_ddb YARN_ENABLE_IMMUTABLE_INSTALLS: false steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + - uses: actions/checkout@v3 with: path: dev + - name: Install Hub Utility + run: sudo apt-get install -y hub + - name: Checkout Pull Request run: hub pr checkout ${{ github.event.issue.number }} working-directory: dev @@ -286,6 +297,9 @@ jobs: - uses: actions/checkout@v3 + - name: Install Hub Utility + run: sudo apt-get install -y hub + - name: Checkout Pull Request run: hub pr checkout ${{ github.event.issue.number }} env: @@ -314,7 +328,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ @@ -331,6 +344,9 @@ jobs: with: path: dev + - name: Install Hub Utility + run: sudo apt-get install -y hub + - name: Checkout Pull Request working-directory: dev run: hub pr checkout ${{ github.event.issue.number }} @@ -473,7 +489,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - CYPRESS_DEPLOYSENTINEL_KEY: ${{ secrets.CYPRESS_DEPLOYSENTINEL_KEY }} CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ @@ -482,10 +497,17 @@ jobs: WEBINY_PULUMI_BACKEND: ${{ secrets.WEBINY_PULUMI_BACKEND }}${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ddb-es YARN_ENABLE_IMMUTABLE_INSTALLS: false steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + - uses: actions/checkout@v3 with: path: dev + - name: Install Hub Utility + run: sudo apt-get install -y hub + - name: Checkout Pull Request working-directory: dev run: hub pr checkout ${{ github.event.issue.number }} diff --git a/.github/workflows/pullRequestsCommandCypress2.yml b/.github/workflows/pullRequestsCommandCypress2.yml new file mode 100644 index 00000000000..ae79459454a --- /dev/null +++ b/.github/workflows/pullRequestsCommandCypress2.yml @@ -0,0 +1,542 @@ +on: issue_comment + +env: + NODE_OPTIONS: --max_old_space_size=4096 + AWS_REGION: eu-central-1 + +name: Pull Requests Command - Cypress + +jobs: + check_comment: + name: Check comment for /cypress2 + runs-on: ubuntu-latest + if: ${{ github.event.issue.pull_request }} + steps: + - name: Check for Command + id: command + uses: xt0rted/slash-command-action@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + command: cypress2 + reaction: "true" + reaction-type: "eyes" + allow-edits: "false" + permission-level: write + + - name: Create comment + uses: peter-evans/create-or-update-comment@v2 + with: + issue-number: ${{ github.event.issue.number }} + body: "Cypress E2E tests have been initiated (for more information, click [here](https://github.com/webiny/webiny-js/actions/runs/${{ github.run_id }})). :sparkles:" + + e2e-wby-cms-ddb-init: + needs: check_comment + name: E2E (DDB) - Init + runs-on: ubuntu-latest + outputs: + day: ${{ steps.get-day.outputs.day }} + ts: ${{ steps.get-timestamp.outputs.ts }} + cypress-folders: ${{ steps.list-cypress-folders.outputs.cypress-folders }} + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - uses: actions/checkout@v3 + + - name: Install Hub Utility + run: sudo apt-get install -y hub + + - name: Checkout Pull Request + run: hub pr checkout ${{ github.event.issue.number }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Get day of the month + id: get-day + run: echo "day=$(node --eval "console.log(new Date().getDate())")" >> $GITHUB_OUTPUT + + - name: Get timestamp + id: get-timestamp + run: echo "ts=$(node --eval "console.log(new Date().getTime())")" >> $GITHUB_OUTPUT + + - name: List Cypress tests folders + id: list-cypress-folders + run: echo "cypress-folders=$(node scripts/listCypressTestsFolders.js)" >> $GITHUB_OUTPUT + + e2e-wby-cms-ddb-project-setup: + name: E2E (DDB) - Project setup + needs: e2e-wby-cms-ddb-init + runs-on: ubuntu-latest + outputs: + cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }} + environment: next + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} + WEBINY_PULUMI_BACKEND: ${{ secrets.WEBINY_PULUMI_BACKEND }}${{ needs.e2e-wby-cms-ddb-init.outputs.ts }}_ddb + YARN_ENABLE_IMMUTABLE_INSTALLS: false + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - uses: actions/checkout@v3 + with: + path: dev + + - name: Install Hub Utility + run: sudo apt-get install -y hub + + - name: Checkout Pull Request + run: hub pr checkout ${{ github.event.issue.number }} + working-directory: dev + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - uses: actions/cache@v3 + id: yarn-cache + with: + path: dev/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('dev/**/yarn.lock') }} + + - uses: actions/cache@v3 + id: cached-packages + with: + path: dev/.webiny/cached-packages + key: ${{ runner.os }}-${{ needs.e2e-wby-cms-ddb-init.outputs.day }}-${{ secrets.RANDOM_CACHE_KEY_SUFFIX }} + + - name: Install dependencies + working-directory: dev + run: yarn --immutable + + - name: Build packages + working-directory: dev + run: yarn build:quick + + - uses: actions/cache@v3 + id: packages-cache + with: + path: dev/.webiny/cached-packages + key: packages-cache-${{ needs.e2e-wby-cms-ddb-init.outputs.ts }} + + # Publish built packages to Verdaccio. + - name: Start Verdaccio local server + working-directory: dev + run: npx pm2 start verdaccio -- -c .verdaccio.yaml + + - name: Create ".npmrc" file in the project root, with a dummy auth token + working-directory: dev + run: echo '//localhost:4873/:_authToken="dummy-auth-token"' > .npmrc + + - name: Configure NPM to use local registry + run: npm config set registry http://localhost:4873 + + - name: Set git email + run: git config --global user.email "webiny-bot@webiny.com" + + - name: Set git username + run: git config --global user.name "webiny-bot" + + - name: Version and publish to Verdaccio + working-directory: dev + run: yarn release --type=verdaccio + + - name: Create verdaccio-files artifact + uses: actions/upload-artifact@v3 + with: + name: verdaccio-files + retention-days: 1 + path: | + dev/.verdaccio/ + dev/.verdaccio.yaml + + # Create a new Webiny project, deploy it, and complete the installation wizard. + - name: Disable Webiny telemetry + run: > + mkdir ~/.webiny && + echo '{ "id": "ci", "telemetry": false }' > ~/.webiny/config + + - name: Create directory + run: mkdir xyz + + - name: Create a new Webiny project + working-directory: xyz + run: > + npx create-webiny-project@local-npm test-project + --tag local-npm --no-interactive + --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}' + --template-options '{"region":"${{ env.AWS_REGION }}","storageOperations":"ddb"}' + + - name: Print CLI version + working-directory: xyz/test-project + run: yarn webiny --version + + - name: Create project-files artifact + uses: actions/upload-artifact@v3 + with: + name: project-files + retention-days: 1 + path: | + xyz/test-project/ + !xyz/test-project/node_modules/**/* + !xyz/test-project/**/node_modules/**/* + !xyz/test-project/.yarn/cache/**/* + + - name: Deploy Core + working-directory: xyz/test-project + run: yarn webiny deploy apps/core --env dev + + - name: Deploy API + working-directory: xyz/test-project + run: yarn webiny deploy apps/api --env dev + + - name: Deploy Admin Area + working-directory: xyz/test-project + run: yarn webiny deploy apps/admin --env dev + + - name: Deploy Website + working-directory: xyz/test-project + run: yarn webiny deploy apps/website --env dev + + # Generates a new cypress-tests/cypress.config.ts config. + - name: Create Cypress config + working-directory: dev + run: yarn setup-cypress --projectFolder ../xyz/test-project + + # We also want to store the generated Cypress config as a job output. + # This way we don't have to generate it again (which may take ~30s). + - name: Save Cypress config + id: save-cypress-config + working-directory: dev + run: echo "cypress-config=$(cat cypress-tests/cypress.config.ts | tr -d '\t\n\r')" >> $GITHUB_OUTPUT + + - name: Cypress - run installation wizard test + working-directory: dev/cypress-tests + run: yarn cypress run --browser chrome --spec "cypress/e2e/adminInstallation/**/*.cy.js" + + e2e-wby-cms-ddb-cypress-tests: + name: ${{ matrix.cypress-folder }} (ddb, ${{ matrix.os }}, Node v${{ matrix.node }}) + needs: [e2e-wby-cms-ddb-init, e2e-wby-cms-ddb-project-setup] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [14] + cypress-folder: ${{ fromJson(needs.e2e-wby-cms-ddb-init.outputs.cypress-folders) }} + runs-on: ubuntu-latest + environment: next + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} + WEBINY_PULUMI_BACKEND: ${{ secrets.WEBINY_PULUMI_BACKEND }}${{ needs.e2e-wby-cms-ddb-init.outputs.ts }}_ddb + YARN_ENABLE_IMMUTABLE_INSTALLS: false + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - uses: actions/checkout@v3 + with: + path: dev + + - name: Install Hub Utility + run: sudo apt-get install -y hub + + - name: Checkout Pull Request + run: hub pr checkout ${{ github.event.issue.number }} + working-directory: dev + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - uses: actions/cache@v3 + with: + path: dev/.webiny/cached-packages + key: packages-cache-${{ needs.e2e-wby-cms-ddb-init.outputs.ts }} + + - uses: actions/cache@v3 + with: + path: dev/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('dev/**/yarn.lock') }} + + - name: Install dependencies + working-directory: dev + run: yarn --immutable + + - name: Build packages + working-directory: dev + run: yarn build:quick + + - name: Set up Cypress config + working-directory: dev + run: echo '${{ needs.e2e-wby-cms-ddb-project-setup.outputs.cypress-config }}' > cypress-tests/cypress.config.ts + + - name: Cypress - run "${{ matrix.cypress-folder }}" tests + timeout-minutes: 40 + working-directory: dev/cypress-tests + run: yarn cypress run --browser chrome --spec "${{ matrix.cypress-folder }}" + + e2e-wby-cms-ddb-es-init: + name: E2E (DDB+ES) - Init + needs: check_comment + runs-on: ubuntu-latest + outputs: + day: ${{ steps.get-day.outputs.day }} + ts: ${{ steps.get-timestamp.outputs.ts }} + cypress-folders: ${{ steps.list-cypress-folders.outputs.cypress-folders }} + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - uses: actions/checkout@v3 + + - name: Install Hub Utility + run: sudo apt-get install -y hub + + - name: Checkout Pull Request + run: hub pr checkout ${{ github.event.issue.number }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Get day of the month + id: get-day + run: echo "day=$(node --eval "console.log(new Date().getDate())")" >> $GITHUB_OUTPUT + + - name: Get timestamp + id: get-timestamp + run: echo "ts=$(node --eval "console.log(new Date().getTime())")" >> $GITHUB_OUTPUT + + - name: List Cypress tests folders + id: list-cypress-folders + run: echo "cypress-folders=$(node scripts/listCypressTestsFolders.js)" >> $GITHUB_OUTPUT + + e2e-wby-cms-ddb-es-project-setup: + name: E2E (DDB+ES) - Project setup + needs: e2e-wby-cms-ddb-es-init + runs-on: ubuntu-latest + outputs: + cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }} + environment: next + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} + ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} + ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} + WEBINY_PULUMI_BACKEND: ${{ secrets.WEBINY_PULUMI_BACKEND }}${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ddb-es + YARN_ENABLE_IMMUTABLE_INSTALLS: false + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - uses: actions/checkout@v3 + with: + path: dev + + - name: Install Hub Utility + run: sudo apt-get install -y hub + + - name: Checkout Pull Request + working-directory: dev + run: hub pr checkout ${{ github.event.issue.number }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - uses: actions/cache@v3 + id: yarn-cache + with: + path: dev/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('dev/**/yarn.lock') }} + + - uses: actions/cache@v3 + id: cached-packages + with: + path: dev/.webiny/cached-packages + key: ${{ runner.os }}-${{ needs.e2e-wby-cms-ddb-es-init.outputs.day }}-${{ secrets.RANDOM_CACHE_KEY_SUFFIX }} + + - name: Install dependencies + working-directory: dev + run: yarn --immutable + + - name: Build packages + working-directory: dev + run: yarn build:quick + + - uses: actions/cache@v3 + id: packages-cache + with: + path: dev/.webiny/cached-packages + key: packages-cache-${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }} + + # Publish built packages to Verdaccio. + - name: Start Verdaccio local server + working-directory: dev + run: npx pm2 start verdaccio -- -c .verdaccio.yaml + + - name: Create ".npmrc" file in the project root, with a dummy auth token + working-directory: dev + run: echo '//localhost:4873/:_authToken="dummy-auth-token"' > .npmrc + + - name: Configure NPM to use local registry + run: npm config set registry http://localhost:4873 + + - name: Set git email + run: git config --global user.email "webiny-bot@webiny.com" + + - name: Set git username + run: git config --global user.name "webiny-bot" + + - name: Version and publish to Verdaccio + working-directory: dev + run: yarn release --type=verdaccio + + - name: Create verdaccio-files artifact + uses: actions/upload-artifact@v3 + with: + name: verdaccio-files + retention-days: 1 + path: | + dev/.verdaccio/ + dev/.verdaccio.yaml + + # Create a new Webiny project, deploy it, and complete the installation wizard. + - name: Disable Webiny telemetry + run: > + mkdir ~/.webiny && + echo '{ "id": "ci", "telemetry": false }' > ~/.webiny/config + + - name: Create directory + run: mkdir xyz + + - name: Create a new Webiny project + working-directory: xyz + run: > + npx create-webiny-project@local-npm test-project + --tag local-npm --no-interactive + --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}' + --template-options '{"region":"${{ env.AWS_REGION }}","storageOperations":"ddb-es"}' + + - name: Print CLI version + working-directory: xyz/test-project + run: yarn webiny --version + + - name: Create project-files artifact + uses: actions/upload-artifact@v3 + with: + name: project-files + retention-days: 1 + path: | + xyz/test-project/ + !xyz/test-project/node_modules/**/* + !xyz/test-project/**/node_modules/**/* + !xyz/test-project/.yarn/cache/**/* + + - name: Deploy Core + working-directory: xyz/test-project + run: yarn webiny deploy apps/core --env dev + + - name: Deploy API + working-directory: xyz/test-project + run: yarn webiny deploy apps/api --env dev + + - name: Deploy Admin Area + working-directory: xyz/test-project + run: yarn webiny deploy apps/admin --env dev + + - name: Deploy Website + working-directory: xyz/test-project + run: yarn webiny deploy apps/website --env dev + + # Generates a new cypress-tests/cypress.config.ts config. + - name: Create Cypress config + working-directory: dev + run: yarn setup-cypress --projectFolder ../xyz/test-project + + # We also want to store the generated Cypress config as a job output. + # This way we don't have to generate it again (which may take ~30s). + - name: Save Cypress config + id: save-cypress-config + working-directory: dev + run: echo "cypress-config=$(cat cypress-tests/cypress.config.ts | tr -d '\t\n\r')" >> $GITHUB_OUTPUT + + - name: Cypress - run installation wizard test + working-directory: dev/cypress-tests + run: yarn cypress run --browser chrome --spec "cypress/e2e/adminInstallation/**/*.cy.js" + + e2e-wby-cms-ddb-es-cypress-tests: + name: ${{ matrix.cypress-folder }} (ddb+es, ${{ matrix.os }}, Node v${{ matrix.node }}) + needs: [e2e-wby-cms-ddb-es-init, e2e-wby-cms-ddb-es-project-setup] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [14] + cypress-folder: ${{ fromJson(needs.e2e-wby-cms-ddb-es-init.outputs.cypress-folders) }} + runs-on: ubuntu-latest + environment: next + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} + ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} + ELASTIC_SEARCH_INDEX_PREFIX: ${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + PULUMI_SECRETS_PROVIDER: ${{ secrets.PULUMI_SECRETS_PROVIDER }} + WEBINY_PULUMI_BACKEND: ${{ secrets.WEBINY_PULUMI_BACKEND }}${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }}_ddb-es + YARN_ENABLE_IMMUTABLE_INSTALLS: false + steps: + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - uses: actions/checkout@v3 + with: + path: dev + + - name: Install Hub Utility + run: sudo apt-get install -y hub + + - name: Checkout Pull Request + working-directory: dev + run: hub pr checkout ${{ github.event.issue.number }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - uses: actions/cache@v3 + with: + path: dev/.webiny/cached-packages + key: packages-cache-${{ needs.e2e-wby-cms-ddb-es-init.outputs.ts }} + + - uses: actions/cache@v3 + with: + path: dev/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('dev/**/yarn.lock') }} + + - name: Install dependencies + working-directory: dev + run: yarn --immutable + + - name: Build packages + working-directory: dev + run: yarn build:quick + + - name: Set up Cypress config + working-directory: dev + run: echo '${{ needs.e2e-wby-cms-ddb-es-project-setup.outputs.cypress-config }}' > cypress-tests/cypress.config.ts + + - name: Cypress - run "${{ matrix.cypress-folder }}" tests + timeout-minutes: 40 + working-directory: dev/cypress-tests + run: yarn cypress run --browser chrome --spec "${{ matrix.cypress-folder }}" diff --git a/apps/theme/layouts/forms/DefaultFormLayout.tsx b/apps/theme/layouts/forms/DefaultFormLayout.tsx index 207064f3e14..7d4425a860a 100644 --- a/apps/theme/layouts/forms/DefaultFormLayout.tsx +++ b/apps/theme/layouts/forms/DefaultFormLayout.tsx @@ -6,10 +6,9 @@ import { Row } from "./DefaultFormLayout/Row"; import { Cell } from "./DefaultFormLayout/Cell"; import { Field } from "./DefaultFormLayout/Field"; import { SuccessMessage } from "./DefaultFormLayout/SuccessMessage"; -import { SubmitButton } from "./DefaultFormLayout/SubmitButton"; import { TermsOfServiceSection } from "./DefaultFormLayout/TermsOfServiceSection"; import { ReCaptchaSection } from "./DefaultFormLayout/ReCaptchaSection"; - +import { Button } from "./DefaultFormLayout/buttons/Button"; const Wrapper = styled.div` width: 100%; padding: 0 5px 5px 5px; @@ -17,6 +16,25 @@ const Wrapper = styled.div` background-color: ${props => props.theme.styles.colors["color6"]}; `; +const ButtonsWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + + & button { + height: 45px; + } + + & button:first-of-type { + margin-right: 15px; + } +`; + +const StepTitle = styled.div` + font-size: 1.2em; + height: 1.2em; +`; + /** * This is the default form layout component, in which we render all the form fields. We also render terms of service * and reCAPTCHA (if enabled in form settings), and, at the very bottom, the submit button. @@ -27,6 +45,13 @@ const DefaultFormLayout: FormLayoutComponent = ({ getFields, getDefaultValues, submit, + goToNextStep, + goToPreviousStep, + isLastStep, + isFirstStep, + isMultiStepForm, + currentStepIndex, + currentStep, formData, ReCaptcha, reCaptchaEnabled, @@ -40,17 +65,20 @@ const DefaultFormLayout: FormLayoutComponent = ({ const [formSuccess, setFormSuccess] = useState(false); // All form fields - an array of rows where each row is an array that contain fields. - const fields = getFields(); - + const fields = getFields(currentStepIndex); /** * Once the data is successfully submitted, we show a success message. */ const submitForm = async (data: Record): Promise => { - setLoading(true); - const result = await submit(data); - setLoading(false); - if (result.error === null) { - setFormSuccess(true); + if (isLastStep) { + setLoading(true); + const result = await submit(data); + setLoading(false); + if (result.error === null) { + setFormSuccess(true); + } + } else { + goToNextStep(); } }; @@ -64,6 +92,7 @@ const DefaultFormLayout: FormLayoutComponent = ({
{({ submit }) => ( + {currentStep?.title} {fields.map((row, rowIndex) => ( {row.map(field => ( @@ -73,17 +102,53 @@ const DefaultFormLayout: FormLayoutComponent = ({ ))} ))} - {termsOfServiceEnabled && } {reCaptchaEnabled && } - - - {formData.settings.submitButtonLabel || "Submit"} - + {/* + If the form has more than one step then the form will be recognized as a Multi Step Form, + so it means that we need to render form step handlers to switch between steps. + */} + {isMultiStepForm && ( + + + {currentStepIndex === formData.steps.length - 1 ? ( + + ) : ( + + )} + + )} + {/* If form is single step then we just render submit button */} + {!isMultiStepForm && ( + + )} )}
diff --git a/apps/theme/layouts/forms/DefaultFormLayout/SubmitButton.tsx b/apps/theme/layouts/forms/DefaultFormLayout/buttons/Button.tsx similarity index 53% rename from apps/theme/layouts/forms/DefaultFormLayout/SubmitButton.tsx rename to apps/theme/layouts/forms/DefaultFormLayout/buttons/Button.tsx index dd20ca5e744..618d20a8760 100644 --- a/apps/theme/layouts/forms/DefaultFormLayout/SubmitButton.tsx +++ b/apps/theme/layouts/forms/DefaultFormLayout/buttons/Button.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { FormRenderPropParamsSubmit } from "@webiny/form"; import styled from "@emotion/styled"; +import { FormRenderPropParamsSubmit } from "@webiny/form"; -export const Wrapper = styled.div<{ fullWidth: boolean }>` - ${props => props.theme.styles.elements["button"]["primary"]} +export const Wrapper = styled.div<{ fullWidth: boolean; type: "primary" | "default" }>` + ${({ theme, type }) => theme.styles.elements["button"][`${type}`]} .button-body { width: ${props => (props.fullWidth ? "100%" : "auto")}; margin-left: auto; @@ -16,15 +16,22 @@ export const Wrapper = styled.div<{ fullWidth: boolean }>` interface Props { fullWidth: boolean; - onClick: FormRenderPropParamsSubmit; - loading: boolean; + disabled: boolean; children: React.ReactNode; + type?: "primary" | "default"; + onClick: FormRenderPropParamsSubmit | (() => void); } -export const SubmitButton: React.FC = ({ fullWidth, onClick, loading, children }) => { +export const Button: React.FC = ({ + fullWidth, + disabled, + children, + type = "default", + onClick +}) => { return ( - - diff --git a/cypress/integration/admin/formBuilder/forms/createForm.spec.js b/cypress/integration/admin/formBuilder/forms/createForm.spec.js index f71237e3a9a..0474d546bf6 100644 --- a/cypress/integration/admin/formBuilder/forms/createForm.spec.js +++ b/cypress/integration/admin/formBuilder/forms/createForm.spec.js @@ -3,147 +3,97 @@ import uniqid from "uniqid"; context("Forms Creation", () => { beforeEach(() => cy.login()); - it("should be able to create, publish, create new revision, and immediately delete everything", () => { - const newFormTitle = `Test form ${uniqid()}`; - const newFormTitle2 = `Test form ${uniqid()}`; + describe("Create Form", () => { + const newFormTitle = `Test form 1 ${uniqid()}`; + const newFormTitle2 = `Test form 2 ${uniqid()}`; - cy.visit("/form-builder/forms"); - cy.findAllByTestId("new-record-button").first().click(); - cy.findByTestId("fb-new-form-modal").within(() => { - cy.findByPlaceholderText("Enter a name for your new form").type(newFormTitle); - cy.findByTestId("fb.form.create").click(); - }); - cy.wait(1000); - cy.findByTestId("fb-editor-form-title").click(); - cy.get(`input[value="${newFormTitle}"]`).clear().type(`${newFormTitle2} {enter}`); - cy.wait(333); - // Add "Email" field to the form - cy.findByTestId("form-editor-field-group-contact").click(); - cy.get(`[data-testid="fb.editor.fields.field.email"]`).drag( - `[data-testid="fb.editor.dropzone.center"]`, - { - force: true - } - ); - cy.wait(1000); + it("should be able to create form, rename it, publish it, create new revision and delete it", () => { + cy.visit("/form-builder/forms"); - cy.findByTestId("fb-editor-back-button").click(); - cy.wait(1000); + // Creating new form. + // After creating new form we should be redirected to the form editing page. + cy.findAllByTestId("new-record-button").first().click(); + cy.findByTestId("fb-new-form-modal").within(() => { + cy.findByPlaceholderText("Enter a name for your new form").type(newFormTitle); + cy.findByTestId("fb.form.create").click(); + }); - cy.findByTestId("default-data-list").within(() => { - cy.get("li") - .first() - .within(() => { - cy.findByText(newFormTitle2); - cy.should("exist"); - cy.findByText(/Draft/i); - cy.should("exist"); - cy.findByText(/\(v1\)/i); - cy.should("exist"); - }); - }); + // Check if we got redirected on form editor page. + cy.findByTestId("add-step-action", { timeout: 15000 }); - // Should only have one revision in form preview revision selector - cy.findByTestId("fb.form-preview.header.revision-selector").click(); - cy.findByTestId("fb.form-preview.header.revision-v1").within(() => { - cy.findByText(/Draft/i); - cy.should("exist"); - cy.findByText(/v1/i); - cy.should("exist"); - }); + // Renaming Form. + cy.findByTestId("fb-editor-form-title").click({ force: true }); + cy.get(`input[value="${newFormTitle}"]`).clear().type(newFormTitle2).blur(); - // Publish the form and check it's status - cy.findByTestId("fb.form-preview.header.publish").click(); - cy.findByTestId("fb.form-preview.header.publish-dialog").within(() => { - cy.findByTestId("confirmationdialog-confirm-action").click(); - }); - cy.findByText(/Successfully published revision/i).should("exist"); - cy.wait(1000); - cy.findByTestId("default-data-list").within(() => { - cy.get("li") - .first() - .within(() => { - cy.findByText(newFormTitle2); - cy.should("exist"); - cy.findByText(/Published/i); - cy.should("exist"); - cy.findByText(/\(v1\)/i); - cy.should("exist"); - }); - }); + // Publishing form after we changed name of it. + cy.findByTestId("fb.editor.default-bar.publish").click({ force: true }); + // Confirming publishing operation in the confirmation dialog. + cy.findByTestId("fb.editor.default-bar.publish-dialog").within(() => { + cy.findByTestId("confirmationdialog-confirm-action").click(); + }); + // Should see this text if publishing operation was successfull. + cy.findByText("Your form was published successfully!"); - // Create a new revision from the published form and check it status - cy.findByTestId("fb.form-preview.header.create-revision").click(); - cy.wait(1000); - cy.findByText(/\(v2\)/i).should("exist"); - cy.findByTestId("fb-editor-back-button").click(); - cy.wait(1000); + // Check if we have renamed form in the list of forms. + cy.findByTestId("default-data-list").within(() => { + cy.findAllByTestId("default-data-list-element") + .first() + .within(() => { + cy.findByText(newFormTitle2); + }); + }); - cy.findByTestId("default-data-list").within(() => { - cy.get("li") - .first() - .within(() => { - cy.findByText(newFormTitle2); - cy.should("exist"); - cy.findByText(/Draft/i); - cy.should("exist"); - cy.findByText(/\(v2\)/i); - cy.should("exist"); - }); - }); + // Should open form edit page for the form with title "newFormTitle2". + cy.findByTestId("default-data-list").within(() => { + cy.findAllByTestId("default-data-list-element") + .first() + .within(() => { + cy.findByText(newFormTitle2).should("be.visible"); + cy.findByTestId("edit-form-action").click({ force: true }); + }); + }); - // Edit form and publish it via the editor, then check the revision status - cy.findByTestId("fb.form-preview.header.edit-revision").click(); - cy.wait(500); - // Add "LastName" field to the form - cy.findByTestId("form-editor-field-group-contact").click(); - cy.get(`[data-testid="fb.editor.fields.field.lastName"]`).drag( - `[data-testid="fb.editor.dropzone.horizontal-last"]`, - { force: true } - ); - cy.wait(500); - cy.findByTestId("fb.editor.default-bar.publish").click(); - cy.findByTestId("fb.editor.default-bar.publish-dialog").within(() => { - cy.findByTestId("confirmationdialog-confirm-action").click(); - }); - cy.findByText(/Your form was published successfully/i).should("exist"); - cy.wait(1000); - cy.findByTestId("default-data-list").within(() => { - cy.get("li") + // Check if we got redirected on form editor page. + cy.findByTestId("add-step-action", { timeout: 15000 }).click({ force: true }); + + // Confirm that we have added a new step. + cy.findAllByTestId("form-step-element").should("have.length", "2"); + + // Publishing form after we added a new step. + cy.findByTestId("fb.editor.default-bar.publish").click({ force: true }); + // Confirming publishing operation in the confirmation dialog. + cy.findByTestId("fb.editor.default-bar.publish-dialog").within(() => { + cy.findByTestId("confirmationdialog-confirm-action").click(); + }); + // Should see this text if publishing operation was successfull. + cy.findByText("Your form was published successfully!"); + + // Check that revision version is 2. + cy.findByTestId("default-data-list").within(() => { + cy.findAllByTestId("default-data-list-element") + .first() + .within(() => { + cy.findByText(newFormTitle2).should("be.visible"); + cy.findByTestId("fb.form.status").within(() => { + cy.findByText("Published (v2)"); + }); + }); + }); + + // Deleting form. + cy.findByTestId("default-data-list").within(() => { + cy.findAllByTestId("default-data-list-element") + .first() + .within(() => { + cy.findByTestId("delete-form-action").click({ force: true }); + }); + }); + + cy.findAllByTestId("form-deletion-confirmation-dialog", { timeout: 15000 }) .first() .within(() => { - cy.findByText(newFormTitle2); - cy.should("exist"); - cy.findByText(/Published/i); - cy.should("exist"); - cy.findByText(/\(v2\)/i); - cy.should("exist"); + cy.findByTestId("confirmationdialog-confirm-action").click({ force: true }); }); }); - // Latest revision should be selected in the revision selector inside form preview - cy.findByTestId("fb.form-preview.header.revision-selector").within(() => { - cy.findByText(/v2/i).should("exist"); - }); - - // Finally, delete the form and it's all revisions - cy.findByTestId("fb.form-preview.header.delete").click(); - cy.wait(500); - cy.findByTestId("fb.form-preview.header.delete-dialog").within(() => { - cy.findByText("Confirmation required!").should("exist"); - // cy.findByText(/Confirm/i).should("exist"); - cy.findByTestId("confirmationdialog-confirm-action").click(); - }); - cy.findByText(/Revision was deleted successfully/i).should("exist"); - cy.wait(500); - - cy.findByTestId("fb.form-preview.header.delete").click(); - cy.wait(500); - cy.findByTestId("fb.form-preview.header.delete-dialog").within(() => { - cy.findByText("Confirmation required!").should("exist"); - // cy.findByText(/Confirm/i).should("exist"); - cy.findByTestId("confirmationdialog-confirm-action").click(); - }); - cy.findByText(/Form was deleted successfully/i).should("exist"); - cy.wait(500); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index af88cfd1b05..96304cddecb 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -32,6 +32,7 @@ import "./fileManager/fmListFiles"; import "./fileManager/fmDeleteFile"; import "./fileManager/fmDeleteAllFiles"; import "./fileManager/fmListTags"; +import "./formBuilder/fbDeleteForm"; import "cypress-mailosaur"; import "./aco/acoNavigateToFolder"; diff --git a/cypress/support/formBuilder/fbDeleteForm.js b/cypress/support/formBuilder/fbDeleteForm.js new file mode 100644 index 00000000000..4b7e67cd0e6 --- /dev/null +++ b/cypress/support/formBuilder/fbDeleteForm.js @@ -0,0 +1,16 @@ +import { GraphQLClient } from "graphql-request"; +import { DELETE_FORM } from "./graphql"; + +Cypress.Commands.add("fbDeleteForm", variables => { + cy.login().then(user => { + const client = new GraphQLClient(Cypress.env("GRAPHQL_API_URL"), { + headers: { + authorization: `Bearer ${user.idToken.jwtToken}` + } + }); + + return client + .request(DELETE_FORM, variables) + .then(responce => responce.formBuilder.deleteForm.data); + }); +}); diff --git a/cypress/support/formBuilder/graphql.js b/cypress/support/formBuilder/graphql.js new file mode 100644 index 00000000000..37a952e4556 --- /dev/null +++ b/cypress/support/formBuilder/graphql.js @@ -0,0 +1,20 @@ +import { gql } from "graphql-request"; + +const ERROR_FIELD = /* GraphQL */ ` + { + code + data + message + } +`; + +export const DELETE_FORM = gql` + mutation DeleteForm($id: ID!) { + formBuilder { + deleteForm(id: $id) { + data + error ${ERROR_FIELD} + } + } + } +`; diff --git a/docs/DEPLOY_WEBINY_PROJECT_CF_TEMPLATE.yaml b/docs/DEPLOY_WEBINY_PROJECT_CF_TEMPLATE.yaml index f05c66ace52..08be64cd103 100644 --- a/docs/DEPLOY_WEBINY_PROJECT_CF_TEMPLATE.yaml +++ b/docs/DEPLOY_WEBINY_PROJECT_CF_TEMPLATE.yaml @@ -127,6 +127,7 @@ Resources: Action: - cognito-idp:CreateUserPoolClient - cognito-idp:DeleteUserPool + - cognito-idp:UpdateUserPool - cognito-idp:DeleteUserPoolClient - cognito-idp:DescribeUserPool - cognito-idp:DescribeUserPoolClient @@ -227,6 +228,15 @@ Resources: - arn:aws:iam::*:role/wby-* - arn:aws:iam::*:policy/wby-* + # AWS Identity and Access Management (IAM) - Service-Linked Roles + # Only needed for the "Amazon DynamoDB + Amazon Elasticsearch" database setup. + # https://www.webiny.com/docs/architecture/introduction#different-database-setups + - Effect: Allow + Action: + - iam:CreateServiceLinkedRole + Resource: + - arn:aws:iam::*:role/aws-service-role/es.amazonaws.com/AWSServiceRoleForAmazonElasticsearchService + # AWS Lambda - Effect: Allow Action: @@ -435,6 +445,13 @@ Resources: - ec2:DescribeSecurityGroups - ec2:DescribeSecurityGroupReferences + # We need this to get logs from CloudWatch during data migrations. + - Effect: Allow + Resource: "*:" + Action: + - logs:GetLogEvents + - logs:Unmask + UserToDeployWebinyProjectGroup1: Type: AWS::IAM::UserToGroupAddition Properties: diff --git a/package.json b/package.json index 3604912ce33..7e69aadafd7 100644 --- a/package.json +++ b/package.json @@ -121,12 +121,14 @@ "add-webiny-package": "node scripts/addWebinyPackage.js", "check-ts-configs": "node scripts/checkTsConfigs.js", "check-package-dependencies": "node scripts/checkPackageNodeModules.js", + "deploy": "yarn webiny deploy", "eslint": "eslint \"**/*.{js,jsx,ts,tsx}\" --max-warnings=0", "eslint:fix": "yarn eslint --fix", "build": "node scripts/buildPackages", "build:quick": "node scripts/buildPackages --build-overrides='{\"tsConfig\":{\"compilerOptions\":{\"skipLibCheck\":true}}}'", "build:apps": "yarn webiny ws run build --scope='@webiny/app*'", "build:api": "yarn webiny ws run build --scope='@webiny/api*' --scope='@webiny/handler*'", + "watch": "yarn webiny watch", "watch:apps": "yarn webiny ws run watch --scope='@webiny/app*'", "watch:api": "yarn webiny ws run watch --scope='@webiny/api*'", "clear-dist": "yarn rimraf packages/*/dist", diff --git a/packages/api-aco/src/apps/AcoApp.ts b/packages/api-aco/src/apps/AcoApp.ts index a2bfe781a95..7b70142e777 100644 --- a/packages/api-aco/src/apps/AcoApp.ts +++ b/packages/api-aco/src/apps/AcoApp.ts @@ -29,28 +29,32 @@ export class AcoApp implements IAcoApp { public get search(): AcoSearchRecordCrudBase { return { create: async (data: CreateSearchRecordParams) => { - return this.context.aco.search.create(this.model, data); + return this.context.aco.search.create(this.getModel(), data); }, update: async (id: string, data: SearchRecord) => { /** * Required to have as any atm as TS is breaking on the return type. */ - return (await this.context.aco.search.update(this.model, id, data)) as any; + return (await this.context.aco.search.update( + this.getModel(), + id, + data + )) as any; }, move: async (id: string, folderId?: string) => { - return this.context.aco.search.move(this.model, id, folderId); + return this.context.aco.search.move(this.getModel(), id, folderId); }, get: async (id: string) => { - return this.context.aco.search.get(this.model, id); + return this.context.aco.search.get(this.getModel(), id); }, list: async (params: ListSearchRecordsParams) => { - return this.context.aco.search.list(this.model, params); + return this.context.aco.search.list(this.getModel(), params); }, delete: async (id: string): Promise => { - return this.context.aco.search.delete(this.model, id); + return this.context.aco.search.delete(this.getModel(), id); }, listTags: async (params: ListSearchRecordTagsParams) => { - return this.context.aco.search.listTags(this.model, params); + return this.context.aco.search.listTags(this.getModel(), params); } }; } @@ -59,6 +63,13 @@ export class AcoApp implements IAcoApp { return this.context.aco.folder; } + private getModel() { + const tenant = this.context.tenancy.getCurrentTenant().id; + const locale = this.context.i18n.getContentLocale()!.code; + + return { ...this.model, tenant, locale }; + } + private constructor(context: AcoContext, params: IAcoAppParams) { this.context = context; this.name = params.name; diff --git a/packages/api-elasticsearch/src/normalize.ts b/packages/api-elasticsearch/src/normalize.ts index 63922b7d669..d0aea515655 100644 --- a/packages/api-elasticsearch/src/normalize.ts +++ b/packages/api-elasticsearch/src/normalize.ts @@ -27,7 +27,7 @@ const specialCharacters = [ "\\*", "\\?", "\\:", - "\\/", + `\/`, "\\#" ]; @@ -43,3 +43,26 @@ export const normalizeValue = (value: string) => { return result || ""; }; + +export const normalizeValueWithAsterisk = (initial: string) => { + const value = normalizeValue(initial); + const results = value.split(" "); + + let result = value; + /** + * If there is a / in the first word, do not put asterisk in front of it. + */ + const firstWord = results[0]; + if (firstWord && firstWord.includes("/") === false) { + result = `*${result}`; + } + /** + * If there is a / in the last word, do not put asterisk at the end of it. + */ + const lastWord = results[results.length - 1]; + if (lastWord && lastWord.includes("/") === false) { + result = `${result}*`; + } + + return result; +}; diff --git a/packages/api-elasticsearch/src/plugins/operator/contains.ts b/packages/api-elasticsearch/src/plugins/operator/contains.ts index e9f02cd667e..9add82f6e74 100644 --- a/packages/api-elasticsearch/src/plugins/operator/contains.ts +++ b/packages/api-elasticsearch/src/plugins/operator/contains.ts @@ -1,5 +1,5 @@ import { ElasticsearchQueryBuilderOperatorPlugin } from "~/plugins/definition/ElasticsearchQueryBuilderOperatorPlugin"; -import { normalizeValue } from "~/normalize"; +import { normalizeValueWithAsterisk } from "~/normalize"; import { ElasticsearchBoolQueryConfig, ElasticsearchQueryBuilderArgsPlugin } from "~/types"; export class ElasticsearchQueryBuilderOperatorContainsPlugin extends ElasticsearchQueryBuilderOperatorPlugin { @@ -18,7 +18,7 @@ export class ElasticsearchQueryBuilderOperatorContainsPlugin extends Elasticsear query_string: { allow_leading_wildcard: true, fields: [basePath], - query: `*${normalizeValue(value)}*`, + query: normalizeValueWithAsterisk(value), default_operator: "and" } }); diff --git a/packages/api-elasticsearch/src/plugins/operator/japanese/contains.ts b/packages/api-elasticsearch/src/plugins/operator/japanese/contains.ts index 9875d44eab2..92530ae9c25 100644 --- a/packages/api-elasticsearch/src/plugins/operator/japanese/contains.ts +++ b/packages/api-elasticsearch/src/plugins/operator/japanese/contains.ts @@ -1,5 +1,5 @@ import { ElasticsearchQueryBuilderOperatorPlugin } from "~/plugins/definition/ElasticsearchQueryBuilderOperatorPlugin"; -import { normalizeValue } from "~/normalize"; +import { normalizeValueWithAsterisk } from "~/normalize"; import { ElasticsearchBoolQueryConfig, ElasticsearchQueryBuilderArgsPlugin } from "~/types"; export class ElasticsearchQueryBuilderJapaneseOperatorContainsPlugin extends ElasticsearchQueryBuilderOperatorPlugin { @@ -22,17 +22,17 @@ export class ElasticsearchQueryBuilderJapaneseOperatorContainsPlugin extends Ela ): void { const { value: initialValue, basePath } = params; - const value = normalizeValue(initialValue); + const value = normalizeValueWithAsterisk(initialValue); query.must.push({ multi_match: { - query: `*${value}*`, + query: value, type: "phrase", fields: [`${basePath}.ngram`] } }); query.should.push({ multi_match: { - query: `*${value}*`, + query: value, type: "phrase", fields: [`${basePath}`] } diff --git a/packages/api-elasticsearch/src/plugins/operator/notContains.ts b/packages/api-elasticsearch/src/plugins/operator/notContains.ts index 220fbb5cf37..30adfb6f350 100644 --- a/packages/api-elasticsearch/src/plugins/operator/notContains.ts +++ b/packages/api-elasticsearch/src/plugins/operator/notContains.ts @@ -1,5 +1,5 @@ import { ElasticsearchQueryBuilderOperatorPlugin } from "~/plugins/definition/ElasticsearchQueryBuilderOperatorPlugin"; -import { normalizeValue } from "~/normalize"; +import { normalizeValueWithAsterisk } from "~/normalize"; import { ElasticsearchBoolQueryConfig, ElasticsearchQueryBuilderArgsPlugin } from "~/types"; export class ElasticsearchQueryBuilderOperatorNotContainsPlugin extends ElasticsearchQueryBuilderOperatorPlugin { @@ -18,7 +18,7 @@ export class ElasticsearchQueryBuilderOperatorNotContainsPlugin extends Elastics query_string: { allow_leading_wildcard: true, fields: [basePath], - query: `*${normalizeValue(value)}*`, + query: normalizeValueWithAsterisk(value), default_operator: "and" } }); diff --git a/packages/api-file-manager/__tests__/file.extensions.test.ts b/packages/api-file-manager/__tests__/file.extensions.test.ts new file mode 100644 index 00000000000..8f7b9712778 --- /dev/null +++ b/packages/api-file-manager/__tests__/file.extensions.test.ts @@ -0,0 +1,114 @@ +import useGqlHandler from "~tests/utils/useGqlHandler"; +import { createFileModelModifier } from "~/modelModifier/CmsModelModifier"; +import { fileAData } from "./mocks/files"; + +describe("File Model Extensions", () => { + const { listFiles, createFile } = useGqlHandler({ + plugins: [ + // Add custom fields that will be assigned to the `extensions` object field. + createFileModelModifier(({ modifier }) => { + modifier.addField({ + id: "carMake", + fieldId: "carMake", + label: "Car Make", + type: "text" + }); + + modifier.addField({ + id: "year", + fieldId: "year", + label: "Year of manufacturing", + type: "number" + }); + modifier.addField({ + id: "aDateTime", + fieldId: "aDateTime", + type: "datetime", + label: "A date time field", + renderer: { + name: "date-time-input" + }, + settings: { + type: "dateTimeWithoutTimezone", + defaultSetValue: "current" + } + }); + modifier.addField({ + id: "article", + fieldId: "article", + label: "Article", + type: "ref", + renderer: { + name: "ref-advanced-single" + }, + settings: { + models: [ + { + modelId: "article" + } + ] + } + }); + }) + ] + }); + + it("should add custom fields to `extensions` object field", async () => { + const extensions = { + carMake: "Honda", + year: 2018, + aDateTime: "2020-01-01T00:00:00.000Z", + article: { + modelId: "article", + id: "abcdefg#0001" + } + }; + const fields = ["extensions { carMake year aDateTime article { id modelId } }"]; + + const [createAResponse] = await createFile( + { + data: { + ...fileAData, + extensions + } + }, + fields + ); + expect(createAResponse).toEqual({ + data: { + fileManager: { + createFile: { + data: { + ...fileAData, + extensions + }, + error: null + } + } + } + }); + + const [listResponse] = await listFiles({}, fields); + + expect(listResponse).toEqual({ + data: { + fileManager: { + listFiles: { + data: [ + { + ...fileAData, + extensions + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-file-manager/__tests__/fileSchema.test.ts b/packages/api-file-manager/__tests__/fileSchema.test.ts index b3a1c703c14..8d0a497cea6 100644 --- a/packages/api-file-manager/__tests__/fileSchema.test.ts +++ b/packages/api-file-manager/__tests__/fileSchema.test.ts @@ -27,6 +27,22 @@ describe("File Model Modifier test", () => { label: "Year of manufacturing", type: "number" }); + modifier.addField({ + id: "article", + fieldId: "article", + label: "Article", + type: "ref", + renderer: { + name: "ref-advanced-single" + }, + settings: { + models: [ + { + modelId: "article" + } + ] + } + }); }) ] }); diff --git a/packages/api-file-manager/__tests__/mocks/file.sdl.ts b/packages/api-file-manager/__tests__/mocks/file.sdl.ts index b753c1c211c..288353d9a70 100644 --- a/packages/api-file-manager/__tests__/mocks/file.sdl.ts +++ b/packages/api-file-manager/__tests__/mocks/file.sdl.ts @@ -64,6 +64,7 @@ export default /* GraphQL */ ` type FmFile_Extensions { carMake: String year: Number + article: RefField } input FmFile_ExtensionsWhereInput { @@ -88,6 +89,8 @@ export default /* GraphQL */ ` year_between: [Number!] # there must be two numbers sent in the array year_not_between: [Number!] + + article: RefFieldWhereInput } type FmFile { @@ -121,6 +124,7 @@ export default /* GraphQL */ ` input FmFile_ExtensionsInput { carMake: String year: Number + article: RefFieldInput } input FmFileCreateInput { diff --git a/packages/api-file-manager/src/graphql/index.ts b/packages/api-file-manager/src/graphql/index.ts index c86112de4a2..636a8167c83 100644 --- a/packages/api-file-manager/src/graphql/index.ts +++ b/packages/api-file-manager/src/graphql/index.ts @@ -5,6 +5,8 @@ import { CmsModel } from "@webiny/api-headless-cms/types"; import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords"; import { createFilesSchema } from "~/graphql/filesSchema"; import { isInstallationPending } from "~/cmsFileStorage/isInstallationPending"; +import { createGraphQLSchemaPluginFromFieldPlugins } from "@webiny/api-headless-cms/utils/getSchemaFromFieldPlugins"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; export const createGraphQLSchemaPlugin = () => { return [ @@ -20,6 +22,19 @@ export const createGraphQLSchemaPlugin = () => { const fileModel = (await context.cms.getModel("fmFile")) as CmsModel; const models = await context.cms.listModels(); const fieldPlugins = createFieldTypePluginRecords(context.plugins); + /** + * We need to register all plugins for all the CMS fields. + */ + const plugins = createGraphQLSchemaPluginFromFieldPlugins({ + models, + type: "manage", + fieldTypePlugins: fieldPlugins, + createPlugin: ({ schema, type, fieldType }) => { + const plugin = new GraphQLSchemaPlugin(schema); + plugin.name = `fm.graphql.schema.${type}.field.${fieldType}`; + return plugin; + } + }); const graphQlPlugin = createFilesSchema({ model: fileModel, @@ -27,7 +42,7 @@ export const createGraphQLSchemaPlugin = () => { plugins: fieldPlugins }); - context.plugins.register(graphQlPlugin); + context.plugins.register([...plugins, graphQlPlugin]); }); }) ]; diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/form.ts b/packages/api-form-builder-so-ddb-es/src/definitions/form.ts index 7a188565cbb..f6d9678ae32 100644 --- a/packages/api-form-builder-so-ddb-es/src/definitions/form.ts +++ b/packages/api-form-builder-so-ddb-es/src/definitions/form.ts @@ -70,7 +70,7 @@ export const createFormEntity = (params: Params): Entity => { fields: { type: "list" }, - layout: { + steps: { type: "list" }, stats: { diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts index 40ae59e4848..d23ab1341f2 100644 --- a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts @@ -44,7 +44,7 @@ export interface CreateFormStorageOperationsParams { plugins: PluginsContainer; } -type FbFormElastic = Omit & { +type FbFormElastic = Omit & { __type: string; }; diff --git a/packages/api-form-builder-so-ddb/src/definitions/form.ts b/packages/api-form-builder-so-ddb/src/definitions/form.ts index 70f17de8ce7..a3d95833aeb 100644 --- a/packages/api-form-builder-so-ddb/src/definitions/form.ts +++ b/packages/api-form-builder-so-ddb/src/definitions/form.ts @@ -76,7 +76,7 @@ export const createFormEntity = (params: Params): Entity => { fields: { type: "list" }, - layout: { + steps: { type: "list" }, stats: { diff --git a/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts b/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts index 65e0d6b3a0d..246f5d029cd 100644 --- a/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts +++ b/packages/api-form-builder/__tests__/formSubmissionSecurity.test.ts @@ -92,7 +92,6 @@ describe("Forms Submission Security Test", () => { version: 1, createdOn: expect.stringMatching(/^20/), savedOn: expect.stringMatching(/^20/), - layout: [], fields: [], locked: false, published: false, @@ -108,6 +107,12 @@ describe("Forms Submission Security Test", () => { views: 0 }, status: "draft", + steps: [ + { + title: "Step 1", + layout: [] + } + ], triggers: null, settings: { reCaptcha: { @@ -140,7 +145,13 @@ describe("Forms Submission Security Test", () => { const [updateFormRevisionResponse] = await handlerA.updateRevision({ revision: formA.id, data: { - fields: mocks.fields + fields: mocks.fields, + steps: [ + { + title: "Test Step", + layout: [] + } + ] } }); expect(updateFormRevisionResponse).toEqual({ @@ -150,7 +161,13 @@ describe("Forms Submission Security Test", () => { data: { ...formA, savedOn: expect.stringMatching(/^20/), - fields: expect.any(Array) + fields: expect.any(Array), + steps: [ + { + title: "Test Step", + layout: [] + } + ] }, error: null @@ -170,6 +187,12 @@ describe("Forms Submission Security Test", () => { ...formA, savedOn: expect.stringMatching(/^20/), fields: expect.any(Array), + steps: [ + { + title: "Test Step", + layout: [] + } + ], status: "published", published: true, publishedOn: expect.stringMatching(/^20/), @@ -194,7 +217,13 @@ describe("Forms Submission Security Test", () => { await handlerB.updateRevision({ revision: formB.id, data: { - fields: mocks.fields + fields: mocks.fields, + steps: [ + { + title: "Test Step", + layout: [] + } + ] } }); diff --git a/packages/api-form-builder/__tests__/forms.test.ts b/packages/api-form-builder/__tests__/forms.test.ts index 3c8dcc99eba..f8682b05296 100644 --- a/packages/api-form-builder/__tests__/forms.test.ts +++ b/packages/api-form-builder/__tests__/forms.test.ts @@ -82,7 +82,12 @@ describe('Form Builder "Form" Test', () => { const newData = { name: "New name", - layout: [["QIspyfQRx", "AVoKqyAuH"], ["fNJag3ZdX"]] + steps: [ + { + title: "Test Step", + layout: [["QIspyfQRx", "AVoKqyAuH"], ["fNJag3ZdX"]] + } + ] }; const [update] = await updateRevision({ revision: id, data: newData }); @@ -105,6 +110,79 @@ describe('Form Builder "Form" Test', () => { expect(data[0].name).toEqual(newData.name); }); + test(`should correctly add step, rename step and remove step from the form`, async () => { + const [create] = await createForm({ data: { name: "general-info" } }); + const { id } = create.data.formBuilder.createForm.data; + + const newDataWithSteps = { + name: "Personal Info", + steps: [ + { + title: "General Info", + layout: [["AVoKqyAuH", "QIspyfQRx"]] + }, + { + title: "Web Info", + layout: [["fNJag3ZdX"]] + } + ] + }; + + const [update] = await updateRevision({ revision: id, data: newDataWithSteps }); + expect(update.data.formBuilder.updateRevision.data).toMatchObject(newDataWithSteps); + + await until( + () => listForms().then(([data]) => data), + ({ data }: any) => data.formBuilder.listForms.data[0].name === newDataWithSteps.name, + { + name: "list forms after adding step" + } + ); + + const [get] = await getForm({ revision: id }); + expect(get.data.formBuilder.getForm.data).toMatchObject(newDataWithSteps); + + const newDataWithRenamedStep = { + name: "Personal Info", + steps: [ + { + title: "General Info", + layout: [["AVoKqyAuH", "QIspyfQRx"]] + }, + { + title: "Email", + layout: [["fNJag3ZdX"]] + } + ] + }; + + const [rename] = await updateRevision({ revision: id, data: newDataWithRenamedStep }); + expect(rename.data.formBuilder.updateRevision.data).toMatchObject(newDataWithRenamedStep); + + const [getFormAfterRename] = await getForm({ revision: id }); + expect(getFormAfterRename.data.formBuilder.getForm.data).toMatchObject( + newDataWithRenamedStep + ); + + const dataAfterRemovingStep = { + name: "Personal Info", + steps: [ + { + title: "Email", + layout: [["fNJag3ZdX"]] + } + ] + }; + + const [remove] = await updateRevision({ revision: id, data: dataAfterRemovingStep }); + expect(remove.data.formBuilder.updateRevision.data).toMatchObject(dataAfterRemovingStep); + + const [getAfterRemovingStep] = await getForm({ revision: id }); + expect(getAfterRemovingStep.data.formBuilder.getForm.data).toMatchObject( + dataAfterRemovingStep + ); + }); + test(`should correctly update the "latest" revision when a revision is deleted`, async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; @@ -290,7 +368,10 @@ describe('Form Builder "Form" Test', () => { const { id } = create.data.formBuilder.createForm.data; // Add fields definitions - await updateRevision({ revision: id, data: { fields } }); + await updateRevision({ + revision: id, + data: { fields, steps: [{ title: "Test Step", layout: [] }] } + }); await publishRevision({ revision: id }); @@ -302,6 +383,7 @@ describe('Form Builder "Form" Test', () => { data: formSubmissionDataA.data, meta: formSubmissionDataA.meta }); + expect(createSubmission1Response).toMatchObject({ data: { formBuilder: { diff --git a/packages/api-form-builder/__tests__/formsSecurity.test.ts b/packages/api-form-builder/__tests__/formsSecurity.test.ts index ba5fc983623..6f702f70abf 100644 --- a/packages/api-form-builder/__tests__/formsSecurity.test.ts +++ b/packages/api-form-builder/__tests__/formsSecurity.test.ts @@ -274,14 +274,30 @@ describe("Forms Security Test", () => { const [permissions, identity] = sufficientPermissions[i]; const { updateRevision } = useGqlHandler({ permissions, identity }); const mock = new Mock(`new-updated-form-`); - const [response] = await updateRevision({ revision: formId, data: mock }); + const [response] = await updateRevision({ + revision: formId, + data: { + ...mock, + steps: [ + { + title: "", + layout: [] + } + ] + } + }); expect(response).toMatchObject({ data: { formBuilder: { updateRevision: { data: { ...new MockResponse({ prefix: `new-updated-form-`, id: formId }), - layout: [] + steps: [ + { + title: "", + layout: [] + } + ] }, error: null } diff --git a/packages/api-form-builder/__tests__/graphql/formSubmission.ts b/packages/api-form-builder/__tests__/graphql/formSubmission.ts index 986bf8cf6ff..644fc49ac95 100644 --- a/packages/api-form-builder/__tests__/graphql/formSubmission.ts +++ b/packages/api-form-builder/__tests__/graphql/formSubmission.ts @@ -11,7 +11,10 @@ export const DATA_FIELD = /* GraphQL */ ` parent name version - layout + steps { + title + layout + } fields { _id fieldId diff --git a/packages/api-form-builder/__tests__/graphql/forms.ts b/packages/api-form-builder/__tests__/graphql/forms.ts index 7a9199911ad..d0c8dec6121 100644 --- a/packages/api-form-builder/__tests__/graphql/forms.ts +++ b/packages/api-form-builder/__tests__/graphql/forms.ts @@ -8,7 +8,10 @@ export const FORM_DATA_FIELD = /* GraphQL */ ` publishedOn version name - layout + steps { + title + layout + } fields { fieldId type diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts index ddbeda51b29..e2f03ade537 100644 --- a/packages/api-form-builder/__tests__/useGqlHandler.ts +++ b/packages/api-form-builder/__tests__/useGqlHandler.ts @@ -44,6 +44,7 @@ import { FileManagerStorageOperations } from "@webiny/api-file-manager/types"; import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; import { CmsParametersPlugin, createHeadlessCmsContext } from "@webiny/api-headless-cms"; import { FormBuilderStorageOperations } from "~/types"; +import { createPageBuilderContext } from "@webiny/api-page-builder"; export interface UseGqlHandlerParams { permissions?: SecurityPermission[]; @@ -64,6 +65,7 @@ export default (params: UseGqlHandlerParams = {}) => { const { permissions, identity, plugins = [] } = params; const i18nStorage = getStorageOps("i18n"); const fileManagerStorage = getStorageOps("fileManager"); + const pageBuilderStorage = getStorageOps("pageBuilder"); const formBuilderStorage = getStorageOps("formBuilder"); const cmsStorage = getStorageOps("cms"); @@ -85,9 +87,13 @@ export default (params: UseGqlHandlerParams = {}) => { }; }), createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }), + createPageBuilderContext({ + storageOperations: pageBuilderStorage.storageOperations + }), createFileManagerContext({ storageOperations: fileManagerStorage.storageOperations }), + createFileManagerGraphQL(), createFormBuilder({ storageOperations: formBuilderStorage.storageOperations diff --git a/packages/api-form-builder/jest.setup.js b/packages/api-form-builder/jest.setup.js index 8a1f510e321..ec51ecdd68d 100644 --- a/packages/api-form-builder/jest.setup.js +++ b/packages/api-form-builder/jest.setup.js @@ -1,6 +1,7 @@ const base = require("../../jest.config.base"); const presets = require("@webiny/project-utils/testing/presets")( ["@webiny/api-form-builder", "storage-operations"], + ["@webiny/api-page-builder", "storage-operations"], ["@webiny/api-file-manager", "storage-operations"], ["@webiny/api-headless-cms", "storage-operations"], ["@webiny/api-i18n", "storage-operations"], diff --git a/packages/api-form-builder/package.json b/packages/api-form-builder/package.json index 3a3c7eb5467..aacb8619896 100644 --- a/packages/api-form-builder/package.json +++ b/packages/api-form-builder/package.json @@ -23,6 +23,7 @@ "@webiny/api-file-manager": "0.0.0", "@webiny/api-i18n": "0.0.0", "@webiny/api-mailer": "0.0.0", + "@webiny/api-page-builder": "0.0.0", "@webiny/api-security": "0.0.0", "@webiny/api-tenancy": "0.0.0", "@webiny/error": "0.0.0", diff --git a/packages/api-form-builder/src/index.ts b/packages/api-form-builder/src/index.ts index a6e886ce945..53cc151ac07 100644 --- a/packages/api-form-builder/src/index.ts +++ b/packages/api-form-builder/src/index.ts @@ -4,6 +4,7 @@ import triggerHandlers from "./plugins/triggers"; import validators from "./plugins/validators"; import formsGraphQL from "./plugins/graphql/form"; import formSettingsGraphQL from "./plugins/graphql/formSettings"; +import formBuilderPrerenderingPlugins from "~/plugins/prerenderingHooks"; import { FormBuilderStorageOperations } from "~/types"; export interface CreateFormBuilderParams { @@ -17,6 +18,7 @@ export const createFormBuilder = (params: CreateFormBuilderParams) => { triggerHandlers, validators, formsGraphQL, - formSettingsGraphQL + formSettingsGraphQL, + formBuilderPrerenderingPlugins() ]; }; diff --git a/packages/api-form-builder/src/plugins/crud/forms.crud.ts b/packages/api-form-builder/src/plugins/crud/forms.crud.ts index 2e308ffa8e8..e750165325c 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.crud.ts @@ -297,7 +297,6 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { }, async createForm(this: FormBuilder, input) { await formsPermissions.ensure({ rwd: "w" }); - const identity = context.security.getIdentity(); const dataModel = new models.FormCreateDataModel().populate(input); await dataModel.validate(); @@ -351,7 +350,14 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { * Will be added via a "update" */ fields: [], - layout: [], + // Our form should always have at least 1 step. + // If we have more then 1 step then the Form will be recognized as a Multi Step Form. + steps: [ + { + title: "Step 1", + layout: [] + } + ], settings: await new models.FormSettingsModel().toJSON(), triggers: null, webinyVersion: context.WEBINY_VERSION @@ -381,7 +387,6 @@ export const createFormsCrud = (params: CreateFormsCrudParams): FormsCRUD => { }, async updateForm(this: FormBuilder, id, input) { await formsPermissions.ensure({ rwd: "w" }); - const updateData = new models.FormUpdateDataModel().populate(input); await updateData.validate(); const data = await updateData.toJSON({ onlyDirty: true }); diff --git a/packages/api-form-builder/src/plugins/crud/forms.models.ts b/packages/api-form-builder/src/plugins/crud/forms.models.ts index 2b0fa629ba1..e5df2a70abb 100644 --- a/packages/api-form-builder/src/plugins/crud/forms.models.ts +++ b/packages/api-form-builder/src/plugins/crud/forms.models.ts @@ -41,6 +41,16 @@ export const FormFieldsModel = withFields({ settings: object({ value: {} }) })(); +export const FormStepsModel = withFields({ + steps: fields({ + value: {}, + instanceOf: withFields({ + title: string(), + layout: object({ value: [] }) + })() + }) +})(); + export const FormSettingsModel = withFields({ layout: fields({ value: {}, @@ -89,7 +99,7 @@ export const FormUpdateDataModel = withFields({ value: [], instanceOf: FormFieldsModel }), - layout: object({ value: [] }), + steps: object({ instanceOf: FormStepsModel, value: {} }), settings: fields({ instanceOf: FormSettingsModel, value: {} }), triggers: object() })(); @@ -118,7 +128,7 @@ export const FormSubmissionCreateDataModel = withFields({ parent: string({ validation: validation.create("required") }), name: string({ validation: validation.create("required") }), version: number({ validation: validation.create("required") }), - layout: object({ value: [] }), + steps: object({ instanceOf: FormStepsModel, value: {} }), fields: fields({ list: true, value: [], diff --git a/packages/api-form-builder/src/plugins/crud/submissions.crud.ts b/packages/api-form-builder/src/plugins/crud/submissions.crud.ts index 82d9c5b2b20..285fa56550d 100644 --- a/packages/api-form-builder/src/plugins/crud/submissions.crud.ts +++ b/packages/api-form-builder/src/plugins/crud/submissions.crud.ts @@ -184,10 +184,8 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm "https://www.google.com/recaptcha/api/siteverify", { method: "POST", - body: JSON.stringify({ - secret: secretKey, - response: reCaptchaResponseToken - }) + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `secret=${secretKey}&response=${reCaptchaResponseToken}` } ); @@ -272,7 +270,7 @@ export const createSubmissionsCrud = (params: CreateSubmissionsCrudParams): Subm name: form.name, version: form.version, fields: form.fields, - layout: form.layout + steps: form.steps } }); diff --git a/packages/api-form-builder/src/plugins/graphql/form.ts b/packages/api-form-builder/src/plugins/graphql/form.ts index abf76198e4b..7b3d13cc8dc 100644 --- a/packages/api-form-builder/src/plugins/graphql/form.ts +++ b/packages/api-form-builder/src/plugins/graphql/form.ts @@ -39,7 +39,7 @@ const plugin: GraphQLSchemaPlugin = { name: String! slug: String! fields: [FbFormFieldType!]! - layout: [[String]]! + steps: [FbFormStepType!]! settings: FbFormSettingsType! triggers: JSON published: Boolean! @@ -54,6 +54,11 @@ const plugin: GraphQLSchemaPlugin = { value: String } + input FbFormStepInput { + title: String + layout: [[String]] + } + input FbFieldOptionsInput { label: String value: String @@ -71,6 +76,11 @@ const plugin: GraphQLSchemaPlugin = { settings: JSON } + type FbFormStepType { + title: String + layout: [[String]] + } + type FbFormFieldType { _id: ID! fieldId: String! @@ -168,7 +178,7 @@ const plugin: GraphQLSchemaPlugin = { input FbUpdateFormInput { name: String fields: [FbFormFieldInput] - layout: [[String]] + steps: [FbFormStepInput] settings: FbFormSettingsInput triggers: JSON } @@ -201,8 +211,8 @@ const plugin: GraphQLSchemaPlugin = { parent: ID name: String version: Int - layout: [[String]] fields: [FbFormFieldType] + steps: [FbFormStepType] } type FbFormSubmission { @@ -444,7 +454,6 @@ const plugin: GraphQLSchemaPlugin = { createRevisionFrom: async (_, args: any, { formBuilder }) => { try { const form = await formBuilder.createFormRevision(args.revision); - return new Response(form); } catch (e) { return new ErrorResponse(e); @@ -456,7 +465,6 @@ const plugin: GraphQLSchemaPlugin = { updateRevision: async (_, args: any, { formBuilder }) => { try { const form = await formBuilder.updateForm(args.revision, args.data); - return new Response(form); } catch (e) { return new ErrorResponse(e); diff --git a/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormDelete.ts b/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormDelete.ts new file mode 100644 index 00000000000..01cb846156f --- /dev/null +++ b/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormDelete.ts @@ -0,0 +1,18 @@ +import { ContextPlugin } from "@webiny/api"; +import { PbContext } from "@webiny/api-page-builder/types"; +import { FormBuilderContext } from "~/types"; + +export default () => { + return new ContextPlugin( + async ({ formBuilder, pageBuilder }) => { + /** + * After a form was deleted, we want to rerender all published pages that include it. + */ + formBuilder.onFormAfterDelete.subscribe(async ({ form }) => { + await pageBuilder.prerendering.render({ + tags: [{ tag: { key: "fb-form", value: form.formId } }] + }); + }); + } + ); +}; diff --git a/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormPublish.ts b/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormPublish.ts new file mode 100644 index 00000000000..e2cadef87d9 --- /dev/null +++ b/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormPublish.ts @@ -0,0 +1,18 @@ +import { ContextPlugin } from "@webiny/api"; +import { FormBuilderContext } from "~/types"; +import { PbContext } from "@webiny/api-page-builder/types"; + +export default () => { + return new ContextPlugin( + async ({ formBuilder, pageBuilder }) => { + /** + * After a form was published, we want to rerender all published pages that include it. + */ + formBuilder.onFormAfterPublish.subscribe(async ({ form }) => { + await pageBuilder.prerendering.render({ + tags: [{ tag: { key: "fb-form", value: form.formId } }] + }); + }); + } + ); +}; diff --git a/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormRevisionDelete.ts b/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormRevisionDelete.ts new file mode 100644 index 00000000000..057c89cbe7b --- /dev/null +++ b/packages/api-form-builder/src/plugins/prerenderingHooks/afterFormRevisionDelete.ts @@ -0,0 +1,19 @@ +import { ContextPlugin } from "@webiny/api"; +import { PbContext } from "@webiny/api-page-builder/types"; +import { FormBuilderContext } from "~/types"; + +export default () => { + return new ContextPlugin( + async ({ formBuilder, pageBuilder }) => { + /** + * If there are published pages that include the form revision + * that was deleted, we need to rerender them. + */ + formBuilder.onFormRevisionAfterDelete.subscribe(async ({ form }) => { + await pageBuilder.prerendering.render({ + tags: [{ tag: { key: "fb-form-revision", value: form.id } }] + }); + }); + } + ); +}; diff --git a/packages/api-form-builder/src/plugins/prerenderingHooks/hooks.ts b/packages/api-form-builder/src/plugins/prerenderingHooks/hooks.ts new file mode 100644 index 00000000000..def9d564e22 --- /dev/null +++ b/packages/api-form-builder/src/plugins/prerenderingHooks/hooks.ts @@ -0,0 +1,11 @@ +import { PbContext } from "@webiny/api-page-builder/types"; +import { ContextPlugin } from "@webiny/api"; +import { FormBuilderContext } from "~/types"; + +import afterFormPublish from "./afterFormPublish"; +import afterFormDelete from "./afterFormDelete"; +import afterFormRevisionDelete from "./afterFormRevisionDelete"; + +export default (): ContextPlugin[] => { + return [afterFormPublish(), afterFormDelete(), afterFormRevisionDelete()]; +}; diff --git a/packages/api-form-builder/src/plugins/prerenderingHooks/index.ts b/packages/api-form-builder/src/plugins/prerenderingHooks/index.ts new file mode 100644 index 00000000000..b1f9f140ece --- /dev/null +++ b/packages/api-form-builder/src/plugins/prerenderingHooks/index.ts @@ -0,0 +1,5 @@ +import hooks from "./hooks"; + +export default () => { + return hooks(); +}; diff --git a/packages/api-form-builder/src/types.ts b/packages/api-form-builder/src/types.ts index c562f9e4dde..70bb94ec153 100644 --- a/packages/api-form-builder/src/types.ts +++ b/packages/api-form-builder/src/types.ts @@ -16,6 +16,11 @@ interface FbSubmissionMeta { [key: string]: any; } +interface FbFormStep { + title: string; + layout: string[][]; +} + interface FbFormFieldValidator { name: string; message: any; @@ -105,7 +110,7 @@ export interface FbForm { publishedOn: string | null; status: string; fields: FbFormField[]; - layout: string[][]; + steps: FbFormStep[]; stats: Omit; settings: Record; triggers: Record | null; @@ -128,7 +133,7 @@ interface FormCreateInput { interface FormUpdateInput { name: string; fields: Record[]; - layout: string[][]; + steps: FbFormStep[]; settings: Record; triggers: Record | null; } @@ -342,6 +347,7 @@ export interface FbSubmission { version: number; fields: Record[]; layout: string[][]; + steps: FbFormStep[]; }; logs: Record[]; createdOn: string; diff --git a/packages/api-form-builder/tsconfig.build.json b/packages/api-form-builder/tsconfig.build.json index 0f5fcbf5c29..179e2d74073 100644 --- a/packages/api-form-builder/tsconfig.build.json +++ b/packages/api-form-builder/tsconfig.build.json @@ -5,6 +5,7 @@ { "path": "../api/tsconfig.build.json" }, { "path": "../api-file-manager/tsconfig.build.json" }, { "path": "../api-i18n/tsconfig.build.json" }, + { "path": "../api-page-builder/tsconfig.build.json" }, { "path": "../api-mailer/tsconfig.build.json" }, { "path": "../api-security/tsconfig.build.json" }, { "path": "../api-tenancy/tsconfig.build.json" }, diff --git a/packages/api-form-builder/tsconfig.json b/packages/api-form-builder/tsconfig.json index c547c4a2e4a..511bf7b4cb2 100644 --- a/packages/api-form-builder/tsconfig.json +++ b/packages/api-form-builder/tsconfig.json @@ -5,6 +5,7 @@ { "path": "../api" }, { "path": "../api-file-manager" }, { "path": "../api-i18n" }, + { "path": "../api-page-builder" }, { "path": "../api-mailer" }, { "path": "../api-security" }, { "path": "../api-tenancy" }, @@ -32,6 +33,8 @@ "@webiny/api-file-manager": ["../api-file-manager/src"], "@webiny/api-i18n/*": ["../api-i18n/src/*"], "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-page-builder/*": ["../api-page-builder/src/*"], + "@webiny/api-page-builder": ["../api-page-builder/src"], "@webiny/api-mailer/*": ["../api-mailer/src/*"], "@webiny/api-mailer": ["../api-mailer/src"], "@webiny/api-security/*": ["../api-security/src/*"], diff --git a/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts index ee1202274fd..76633a6cda6 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts @@ -6,6 +6,13 @@ import { useAuthorManageHandler } from "~tests/testHelpers/useAuthorManageHandle const singularPageApiName = pageModel.singularApiName; +const withTemplateId = (data: Record) => { + return { + ...data, + content: data.content.map((obj: any) => ({ ...obj, _templateId: expect.any(String) })) + }; +}; + const contentEntryQueryData = { content: [ { @@ -285,7 +292,7 @@ describe("dynamicZone field", () => { createPage: { data: { id: expect.any(String), - ...contentEntryQueryData + ...withTemplateId(contentEntryQueryData) }, error: null } @@ -302,7 +309,7 @@ describe("dynamicZone field", () => { updatePage: { data: { id: expect.any(String), - ...contentEntryQueryData + ...withTemplateId(contentEntryQueryData) }, error: null } @@ -319,7 +326,7 @@ describe("dynamicZone field", () => { data: [ { id: page.id, - ...contentEntryQueryData + ...withTemplateId(contentEntryQueryData) } ], meta: { @@ -342,7 +349,7 @@ describe("dynamicZone field", () => { getPage: { data: { id: page.id, - ...contentEntryQueryData + ...withTemplateId(contentEntryQueryData) }, error: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/search.test.ts b/packages/api-headless-cms/__tests__/contentAPI/search.test.ts index 57297a4a547..f1f3c076114 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/search.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/search.test.ts @@ -22,8 +22,8 @@ describe("search", () => { return `A ${fruit} a`; }; - const createFruits = async () => { - const fruits = ["strawb-erry", "straw-berry", "strawberry"]; + const createFruits = async (input?: string[]) => { + const fruits = input || ["strawb-erry", "straw-berry", "strawberry"]; return Promise.all( fruits.map(fruit => { @@ -39,13 +39,13 @@ describe("search", () => { ); }; - const setupFruits = async () => { + const setupFruits = async (input?: string[]) => { const group = await setupContentModelGroup(fruitManager); await setupContentModels(fruitManager, group, ["fruit"]); - return createFruits(); + return createFruits(input); }; - it("should find record with dash in the middle of two words", async () => { + it.skip("should find record with dash in the middle of two words", async () => { await setupFruits(); const [response] = await listFruits({ where: { @@ -66,4 +66,269 @@ describe("search", () => { } }); }); + + it("should find record with w/ in title", async () => { + const fruits = { + apple: "app w/ le", + banana: "banana w/", + orange: "w/ orange", + grape: "gr w/ ape" + }; + await setupFruits(Object.values(fruits)); + + const [initialResponse] = await listFruits({ + sort: ["createdOn_ASC"] + }); + expect(initialResponse).toMatchObject({ + data: { + listFruits: { + data: expect.any(Array), + meta: { + totalCount: 4, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + /** + * Apple + */ + const [appleOnEnd] = await listFruits({ + where: { + name_contains: "app w/" + } + }); + expect(appleOnEnd).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.apple) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + + const [appleOnStart] = await listFruits({ + where: { + name_contains: "w/ le" + } + }); + expect(appleOnStart).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.apple) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + + const [appleInMiddle] = await listFruits({ + where: { + name_contains: "p w/ l" + } + }); + expect(appleInMiddle).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.apple) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + /** + * Banana + */ + const [bananaOnEnd] = await listFruits({ + where: { + name_contains: "ana w/" + } + }); + expect(bananaOnEnd).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.banana) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + + const [bananaInMiddle] = await listFruits({ + where: { + name_contains: "banana w/" + } + }); + expect(bananaInMiddle).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.banana) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + /** + * Orange + */ + const [orangeOnStart] = await listFruits({ + where: { + name_contains: "w/ ora" + } + }); + expect(orangeOnStart).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.orange) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + + const [orangeInMiddle] = await listFruits({ + where: { + name_contains: "w/ orange" + } + }); + expect(orangeInMiddle).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.orange) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + /** + * Grape + */ + const [grapeOnEnd] = await listFruits({ + where: { + name_contains: "gr w/" + } + }); + expect(grapeOnEnd).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.grape) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + + const [grapeOnStart] = await listFruits({ + where: { + name_contains: "w/ ape" + } + }); + expect(grapeOnStart).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.grape) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + + const [grapeInMiddle] = await listFruits({ + where: { + name_contains: "r w/ ap" + } + }); + expect(grapeInMiddle).toMatchObject({ + data: { + listFruits: { + data: [ + { + name: createName(fruits.grape) + } + ], + meta: { + totalCount: 1, + hasMoreItems: false, + cursor: null + }, + error: null + } + } + }); + }); }); diff --git a/packages/api-headless-cms/__tests__/testHelpers/usePageManageHandler.ts b/packages/api-headless-cms/__tests__/testHelpers/usePageManageHandler.ts index dfe686fd400..0ec37bd034d 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/usePageManageHandler.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/usePageManageHandler.ts @@ -9,10 +9,12 @@ const pageFields = ` content { ...on ${singularPageApiName}_Content_Hero { title + _templateId __typename } ...on ${singularPageApiName}_Content_SimpleText { text + _templateId __typename } ...on ${singularPageApiName}_Content_Objecting { @@ -32,6 +34,7 @@ const pageFields = ` } } } + _templateId __typename } ...on ${singularPageApiName}_Content_Author { @@ -43,6 +46,7 @@ const pageFields = ` id modelId } + _templateId __typename } } diff --git a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts index f0a6bc54b42..dac4f3b5e58 100644 --- a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts +++ b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts @@ -1,5 +1,7 @@ -import { CmsModel, CmsFieldTypePlugins, ApiEndpoint } from "~/types"; +import { ApiEndpoint, CmsContext, CmsFieldTypePlugins, CmsModel } from "~/types"; import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { GraphQLSchemaDefinition } from "@webiny/handler-graphql/types"; const TYPE_MAP: Record = { preview: "read", @@ -7,13 +9,30 @@ const TYPE_MAP: Record = { manage: "manage" }; +interface CreatePluginCallableParams { + schema: GraphQLSchemaDefinition; + type: "manage" | "preview" | "read"; + fieldType: string; +} + +interface CreatePluginCallable { + (params: CreatePluginCallableParams): GraphQLSchemaPlugin; +} + +const defaultCreatePlugin: CreatePluginCallable = ({ schema, type, fieldType }) => { + const plugin = new CmsGraphQLSchemaPlugin(schema); + plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldType}`; + return plugin; +}; + interface Params { models: CmsModel[]; fieldTypePlugins: CmsFieldTypePlugins; type: ApiEndpoint; + createPlugin?: CreatePluginCallable; } export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { - const { models, fieldTypePlugins, type } = params; + const { models, fieldTypePlugins, type, createPlugin = defaultCreatePlugin } = params; const plugins: CmsGraphQLSchemaPlugin[] = []; for (const key in fieldTypePlugins) { @@ -28,8 +47,13 @@ export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { } const schema = createSchema({ models }); - const plugin = new CmsGraphQLSchemaPlugin(schema); - plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldTypePlugin.fieldType}`; + // const plugin = new CmsGraphQLSchemaPlugin(schema); + // plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldTypePlugin.fieldType}`; + const plugin = createPlugin({ + schema, + type, + fieldType: fieldTypePlugin.fieldType + }); plugins.push(plugin); } return plugins; diff --git a/packages/api-page-builder-import-export/src/export/process/exporters/FormExporter.ts b/packages/api-page-builder-import-export/src/export/process/exporters/FormExporter.ts index 9fc21489d35..72681954c2d 100644 --- a/packages/api-page-builder-import-export/src/export/process/exporters/FormExporter.ts +++ b/packages/api-page-builder-import-export/src/export/process/exporters/FormExporter.ts @@ -5,7 +5,7 @@ import { File } from "@webiny/api-file-manager/types"; export interface ExportedFormData { form: Pick< FbForm, - "name" | "status" | "version" | "fields" | "layout" | "settings" | "triggers" + "name" | "status" | "version" | "fields" | "steps" | "settings" | "triggers" >; files: File[]; } @@ -18,7 +18,7 @@ export class FormExporter { status: form.status, version: form.version, fields: form.fields, - layout: form.layout, + steps: form.steps, settings: form.settings, triggers: form.triggers } diff --git a/packages/api-page-builder-import-export/src/import/process/forms/formsHandler.ts b/packages/api-page-builder-import-export/src/import/process/forms/formsHandler.ts index 7e459f754bb..539de557fb9 100644 --- a/packages/api-page-builder-import-export/src/import/process/forms/formsHandler.ts +++ b/packages/api-page-builder-import-export/src/import/process/forms/formsHandler.ts @@ -77,7 +77,7 @@ export const formsHandler = async ( fbForm = await formBuilder.updateForm(fbForm.id, { name: form.name, fields: form.fields, - layout: form.layout, + steps: form.steps, settings: form.settings, triggers: form.triggers }); diff --git a/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts b/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts index 65d260a6768..a4f16f9d635 100644 --- a/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts @@ -50,7 +50,11 @@ const getDefaultContent = () => { return { id: uniqid.time(), type: "document", - data: {}, + data: { + template: { + variables: [] + } + }, elements: [] }; }; diff --git a/packages/api-prerendering-service/src/flush/index.ts b/packages/api-prerendering-service/src/flush/index.ts index 011a8763045..cdf130fd5ad 100644 --- a/packages/api-prerendering-service/src/flush/index.ts +++ b/packages/api-prerendering-service/src/flush/index.ts @@ -1,7 +1,7 @@ import S3 from "aws-sdk/clients/s3"; import { join } from "path"; import WebinyError from "@webiny/error"; -import { getStorageFolder } from "~/utils"; +import { getStorageFolder, isMultiTenancyEnabled } from "~/utils"; import { FlushHookPlugin, HandlerArgs } from "./types"; import { PrerenderingServiceStorageOperations } from "~/types"; import { EventPlugin } from "@webiny/handler"; @@ -12,6 +12,7 @@ interface DeleteFileParams { key: string; storageName: string; } + const deleteFile = ({ key, storageName }: DeleteFileParams) => { return s3 .deleteObject({ @@ -27,6 +28,7 @@ export interface Params { export default (configuration: Params) => { const { storageOperations } = configuration; + const isMultiTenant = isMultiTenancyEnabled(); return new EventPlugin(async ({ payload, context }) => { const log = console.log; @@ -45,6 +47,8 @@ export default (configuration: Params) => { return; } + const bucketRoot = isMultiTenant ? tenant : ""; + return new Promise(async (resolve?: any) => { const render = await storageOperations.getRender({ where: { path, tenant } @@ -71,7 +75,7 @@ export default (configuration: Params) => { if (Array.isArray(render.files)) { for (const file of render.files) { - const key = join(storageFolder, file.name); + const key = join(bucketRoot, storageFolder, file.name); await deleteFile({ key, storageName: settings.bucket }); } diff --git a/packages/api-prerendering-service/src/render/index.ts b/packages/api-prerendering-service/src/render/index.ts index fb03a11993e..1f374790166 100644 --- a/packages/api-prerendering-service/src/render/index.ts +++ b/packages/api-prerendering-service/src/render/index.ts @@ -1,7 +1,7 @@ import renderUrl, { File } from "./renderUrl"; import { join } from "path"; import S3 from "aws-sdk/clients/s3"; -import { getStorageFolder, getRenderUrl, getIsNotFoundPage } from "~/utils"; +import { getStorageFolder, getRenderUrl, getIsNotFoundPage, isMultiTenancyEnabled } from "~/utils"; import { HandlerPayload, RenderHookPlugin } from "./types"; import { PrerenderingServiceStorageOperations, Render, TagPathLink } from "~/types"; import omit from "lodash/omit"; @@ -17,6 +17,7 @@ interface StoreFileParams { body: string; storageName: string; } + const storeFile = (params: StoreFileParams) => { const { storageName, key, contentType, body } = params; const object: S3.Types.PutObjectRequest = { @@ -40,16 +41,6 @@ export interface RenderParams { const NOT_FOUND_FOLDER = "_NOT_FOUND_PAGE_"; -function isMultiTenancyEnabled() { - // This check is for backwards compatibility with pre-5.29.0 projects. - if (process.env.WEBINY_MULTI_TENANCY === "true") { - return true; - } - - // For >=5.29.0 projects, check for `WCP_PROJECT_ENVIRONMENT` variable. - return process.env.hasOwnProperty("WCP_PROJECT_ENVIRONMENT"); -} - export default (params: RenderParams) => { const { storageOperations } = params; const isMultiTenant = isMultiTenancyEnabled(); diff --git a/packages/api-prerendering-service/src/utils/index.ts b/packages/api-prerendering-service/src/utils/index.ts index b4cb271129f..94b58255c94 100644 --- a/packages/api-prerendering-service/src/utils/index.ts +++ b/packages/api-prerendering-service/src/utils/index.ts @@ -1,4 +1,5 @@ export { getRenderUrl } from "./getRenderUrl"; export { getStorageFolder } from "./getStorageFolder"; export { getIsNotFoundPage } from "./getIsNotFoundPage"; +export { isMultiTenancyEnabled } from "./isMultiTenancyEnabled"; export { log } from "./log"; diff --git a/packages/api-prerendering-service/src/utils/isMultiTenancyEnabled.ts b/packages/api-prerendering-service/src/utils/isMultiTenancyEnabled.ts new file mode 100644 index 00000000000..4b43a70247a --- /dev/null +++ b/packages/api-prerendering-service/src/utils/isMultiTenancyEnabled.ts @@ -0,0 +1,9 @@ +export function isMultiTenancyEnabled() { + // This check is for backwards compatibility with pre-5.29.0 projects. + if (process.env.WEBINY_MULTI_TENANCY === "true") { + return true; + } + + // For >=5.29.0 projects, check for `WCP_PROJECT_ENVIRONMENT` variable. + return process.env.hasOwnProperty("WCP_PROJECT_ENVIRONMENT"); +} diff --git a/packages/app-aco/src/contexts/records.tsx b/packages/app-aco/src/contexts/records.tsx index bc55851f313..b74a95e680c 100644 --- a/packages/app-aco/src/contexts/records.tsx +++ b/packages/app-aco/src/contexts/records.tsx @@ -219,11 +219,6 @@ export const SearchRecordsProvider: React.VFC = ({ children }) => { } setRecords(prev => { - // If no data received, return the previous state - if (!data.length) { - return prev; - } - // If there's no cursor, it means we're receiving a new list of records from scratch. if (!after) { return data; @@ -248,18 +243,20 @@ export const SearchRecordsProvider: React.VFC = ({ children }) => { throw new Error("Record `id` is mandatory"); } + const { id: recordId } = parseIdentifier(id); + const { data: response } = await apolloFetchingHandler( loadingHandler("GET", setLoading), () => client.query({ query: GET_RECORD, - variables: { id }, + variables: { id: recordId }, fetchPolicy: "network-only" }) ); if (!response) { - throw new Error(`Could not fetch record "${id}" - no response.`); + throw new Error(`Could not fetch record "${recordId}" - no response.`); } const { data, error } = getResponseData(response, mode); @@ -271,12 +268,12 @@ export const SearchRecordsProvider: React.VFC = ({ children }) => { if (!data) { // No record found - must be deleted by previous operation setRecords(prev => { - return prev.filter(record => record.id !== id); + return prev.filter(record => record.id !== recordId); }); return data; } setRecords(prev => { - const index = prev.findIndex(record => record.id === id); + const index = prev.findIndex(record => record.id === recordId); // No record found in the list - must be added by previous operation if (index === -1) { diff --git a/packages/app-aco/src/graphql/folders.gql.ts b/packages/app-aco/src/graphql/folders.gql.ts index ac927532f5f..2df3372f721 100644 --- a/packages/app-aco/src/graphql/folders.gql.ts +++ b/packages/app-aco/src/graphql/folders.gql.ts @@ -36,9 +36,9 @@ export const CREATE_FOLDER = gql` `; export const LIST_FOLDERS = gql` - query ListFolders ($type: String!) { + query ListFolders ($type: String!, $limit: Int!) { aco { - listFolders(where: { type: $type }) { + listFolders(where: { type: $type }, limit: $limit) { data ${DATA_FIELD} error ${ERROR_FIELD} } diff --git a/packages/app-file-manager/src/components/FileDetails/FileDetails.tsx b/packages/app-file-manager/src/components/FileDetails/FileDetails.tsx index e56883a8d10..e7805e20e06 100644 --- a/packages/app-file-manager/src/components/FileDetails/FileDetails.tsx +++ b/packages/app-file-manager/src/components/FileDetails/FileDetails.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useMemo, useState } from "react"; // @ts-ignore import { useHotkeys } from "react-hotkeyz"; import omit from "lodash/omit"; @@ -9,9 +9,6 @@ import { Drawer, DrawerContent } from "@webiny/ui/Drawer"; import { CircularProgress } from "@webiny/ui/Progress"; import { Cell, Grid } from "@webiny/ui/Grid"; import { Tab, Tabs } from "@webiny/ui/Tabs"; -import { Aliases } from "./components/Aliases"; -import { Name } from "./components/Name"; -import { Tags } from "./components/Tags"; import { FileDetailsProvider } from "~/components/FileDetails/FileDetailsProvider"; import { Preview } from "./components/Preview"; import { PreviewMeta } from "./components/PreviewMeta"; @@ -27,6 +24,8 @@ import { useFileManagerView, useFileManagerViewConfig } from "~/index"; import { useSnackbar } from "@webiny/app-admin"; import { useFileDetails } from "~/hooks/useFileDetails"; import { FileProvider } from "~/contexts/FileProvider"; +import { prepareFormData } from "@webiny/app-headless-cms-common"; +import { CmsModelField } from "@webiny/app-headless-cms/types"; type FileDetailsDrawerProps = React.ComponentProps & { width: string }; @@ -51,20 +50,37 @@ interface FileDetailsInnerProps { onClose: () => void; } +const prepareFileData = (data: Record, fields: CmsModelField[]) => { + const output = omit(data, ["createdBy", "createdOn", "src"]); + if (fields.length === 0) { + return output; + } + return { + ...output, + extensions: prepareFormData(output.extensions, fields) + }; +}; + const FileDetailsInner: React.FC = ({ file }) => { const [isLoading, setLoading] = useState(false); const { showSnackbar } = useSnackbar(); const fileModel = useFileModel(); const { updateFile } = useFileManagerView(); const { close } = useFileDetails(); + const { fileDetails } = useFileManagerViewConfig(); - const hasExtensions = useMemo(() => { - return fileModel.fields.find(field => field.fieldId === "extensions"); + const extensionFields = useMemo(() => { + const fields = fileModel.fields.find(field => field.fieldId === "extensions"); + if (!fields?.settings?.fields) { + return []; + } + return fields?.settings?.fields || []; }, [fileModel]); const onSubmit: FormOnSubmit = async ({ id, ...data }) => { setLoading(true); - await updateFile(id, omit(data, ["createdBy", "createdOn", "src"])); + const fileData = prepareFileData(data, extensionFields); + await updateFile(id, fileData); setLoading(false); showSnackbar("File updated successfully!"); close(); @@ -89,18 +105,14 @@ const FileDetailsInner: React.FC = ({ file }) => { - - - - - - - - - + {fileDetails.fields.map(field => ( + + {field.element} + + ))} - {hasExtensions ? ( + {extensionFields.length > 0 ? ( diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerView.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerView.tsx index 80cd21629fe..eac49d4782b 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerView.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerView.tsx @@ -20,7 +20,7 @@ import { Tooltip } from "@webiny/ui/Tooltip"; import { useFileManagerView } from "~/modules/FileManagerRenderer/FileManagerViewProvider"; import { outputFileSelectionError } from "./outputFileSelectionError"; import { LeftSidebar } from "./LeftSidebar"; -import { useFileManagerApi } from "~/index"; +import { useFileManagerApi, useFileManagerViewConfig } from "~/index"; import { FileItem } from "@webiny/app-admin/types"; import { BottomInfoBar } from "~/components/BottomInfoBar"; import { DropFilesHere } from "~/components/DropFilesHere"; @@ -73,6 +73,7 @@ const createSort = (sorting?: Sorting): ListFilesSort | undefined => { const FileManagerView = () => { const view = useFileManagerView(); const fileManager = useFileManagerApi(); + const { browser } = useFileManagerViewConfig(); const { showSnackbar } = useSnackbar(); const uploader = useMemo( @@ -317,12 +318,14 @@ const FileManagerView = () => { currentFolder={view.folderId} onFolderClick={view.setFolderId} > - + {browser.filterByTags ? ( + + ) : null} ({ browser: { ...browser, + filterByTags: browser.filterByTags ?? false, filters: [...(browser.filters || [])], filtersToWhere: [...(browser.filtersToWhere || [])] }, - fileDetails: config.fileDetails || { - width: "1000px" + fileDetails: { + width: config.fileDetails?.width ?? "1000px", + fields: config.fileDetails?.fields ?? [] } }), [config] diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/LeftSidebar.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/LeftSidebar.tsx index b48971c4d8e..6158247537f 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/LeftSidebar.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/LeftSidebar.tsx @@ -38,7 +38,7 @@ export const LeftSidebar: React.FC = ({ enableActions={true} enableCreate={true} /> - + {children ? : null} {children} ); diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FilterByTags.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FilterByTags.tsx new file mode 100644 index 00000000000..4f9151902b1 --- /dev/null +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FilterByTags.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Property } from "@webiny/react-properties"; + +export interface FilterByTagProps { + remove?: boolean; +} + +export const FilterByTags = ({ remove }: FilterByTagProps) => { + return ( + + + + ); +}; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/index.ts b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/index.ts index 7ad7fbfd085..e46cd78082e 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/index.ts +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/index.ts @@ -1,12 +1,15 @@ import { Filter, FilterConfig } from "./Filter"; import { FiltersToWhere, FiltersToWhereConverter } from "./FiltersToWhere"; +import { FilterByTags } from "./FilterByTags"; export interface BrowserConfig { filters: FilterConfig[]; filtersToWhere: FiltersToWhereConverter[]; + filterByTags: Boolean; } export const Browser = { Filter, - FiltersToWhere + FiltersToWhere, + FilterByTags }; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/Field.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/Field.tsx new file mode 100644 index 00000000000..e5887751dba --- /dev/null +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/Field.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Property, useIdGenerator } from "@webiny/react-properties"; +import { makeComposable, createDecoratorFactory } from "@webiny/app-admin"; + +export interface FieldConfig { + name: string; + element: React.ReactElement; +} + +export interface FieldProps { + name: string; + element?: React.ReactElement; + remove?: boolean; + before?: string; + after?: string; +} + +const BaseField = makeComposable( + "Field", + ({ name, element, after = undefined, before = undefined, remove = false }) => { + const getId = useIdGenerator("field"); + const placeAfter = after !== undefined ? getId(after) : undefined; + const placeBefore = before !== undefined ? getId(before) : undefined; + + return ( + + + + {element ? ( + + ) : null} + + + ); + } +); + +const createDecorator = createDecoratorFactory<{ name: string }>()( + BaseField, + (decoratorProps, componentProps) => { + if (decoratorProps.name === "*") { + return true; + } + + return decoratorProps.name === componentProps.name; + } +); + +export const Field = Object.assign(BaseField, { createDecorator }); diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/index.ts b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/index.ts index e7fa712f494..b60732241f4 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/index.ts +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/FileDetails/index.ts @@ -1,12 +1,15 @@ +import { Field, FieldConfig } from "./Field"; import { createScopedFieldDecorator } from "./FieldDecorator"; import { Width } from "./Width"; export interface FileDetailsConfig { width: string; + fields: FieldConfig[]; } export const FileDetails = { Width, + Field, ExtensionField: { createDecorator: createScopedFieldDecorator("fm.fileDetails.extensionFields") } diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/index.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/index.tsx index aa726f1c3ce..cf4a98b2920 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/index.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/index.tsx @@ -2,15 +2,22 @@ import React from "react"; import { FileManagerViewConfig as FileManagerConfig } from "~/index"; import { FileManagerRenderer } from "./FileManagerView"; import { FilterByType } from "./filters/FilterByType"; +import { Name } from "~/components/FileDetails/components/Name"; +import { Tags } from "~/components/FileDetails/components/Tags"; +import { Aliases } from "~/components/FileDetails/components/Aliases"; -const { Browser } = FileManagerConfig; +const { Browser, FileDetails } = FileManagerConfig; export const FileManagerRendererModule = () => { return ( <> + } /> + } /> + } /> + } /> ); diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts index 491512b4302..a07e265f87d 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/deleteField.ts @@ -1,10 +1,11 @@ -import { FbFormModelField, FbFormModel, FbFormModelFieldsLayout } from "~/types"; +import { FbFormModelField, FbFormModel, FbFormModelFieldsLayout, FbFormStep } from "~/types"; interface Params { field: FbFormModelField; data: FbFormModel; + targetStepId: string; } -export default ({ field, data }: Params): FbFormModel => { +export default ({ field, data, targetStepId }: Params): FbFormModel => { // Remove the field from fields list... const fieldIndex = data.fields.findIndex(item => item._id === field._id); data.fields.splice(fieldIndex, 1); @@ -17,8 +18,10 @@ export default ({ field, data }: Params): FbFormModel => { // ...and rebuild the layout object. const layout: FbFormModelFieldsLayout = []; + const targetStepLayout = data.steps.find(s => s.id === targetStepId) as FbFormStep; let currentRowIndex = 0; - data.layout.forEach(row => { + + targetStepLayout.layout.forEach(row => { row.forEach(fieldId => { const field = data.fields.find(item => item._id === fieldId); if (!field) { @@ -33,6 +36,6 @@ export default ({ field, data }: Params): FbFormModel => { layout[currentRowIndex] && layout[currentRowIndex].length && currentRowIndex++; }); - data.layout = layout; + targetStepLayout.layout = layout; return data; }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts index 8f46c4262db..509d12e1b61 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/getFieldPosition.ts @@ -1,11 +1,11 @@ -import { FbFormModel, FbFormModelField, FieldIdType, FieldLayoutPositionType } from "~/types"; +import { FbFormModelField, FieldIdType, FieldLayoutPositionType, FbFormStep } from "~/types"; interface GetFieldPositionResult extends Omit { index: number; } interface GetFieldPositionParams { field: FbFormModelField | FieldIdType; - data: FbFormModel; + data: FbFormStep; } export default ({ field, data }: GetFieldPositionParams): GetFieldPositionResult | null => { diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts index cb2e760a993..2bf9e1596ab 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/index.ts @@ -2,3 +2,6 @@ export { default as getFieldPosition } from "./getFieldPosition"; export { default as moveField } from "./moveField"; export { default as deleteField } from "./deleteField"; export { default as moveRow } from "./moveRow"; +export { default as moveStep } from "./moveStep"; +export { default as moveFieldBetweenSteps } from "./moveFieldBetweenSteps"; +export { default as moveRowBetweenSteps } from "./moveRowBetweenSteps"; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts index 06b97a1ceb7..4847b58e5b2 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveField.ts @@ -1,22 +1,33 @@ -import { FbFormModel, FbFormModelField, FieldIdType, FieldLayoutPositionType } from "~/types"; +import { + FbFormModel, + FbFormModelField, + FbFormStep, + FieldIdType, + FieldLayoutPositionType +} from "~/types"; import getFieldPosition from "./getFieldPosition"; /** * Remove all rows that have zero fields in it. * @param data */ -const cleanupEmptyRows = (data: FbFormModel): void => { - data.layout = data.layout.filter(row => row.length > 0); + +const cleanupEmptyRows = (params: MoveFieldParams): void => { + const { data, targetStepId } = params; + const targetStep = data.steps.find(s => s.id === targetStepId) as FbFormStep; + + targetStep.layout = targetStep?.layout.filter(row => row.length > 0); }; interface MoveFieldParams { field: FieldIdType | FbFormModelField; position: FieldLayoutPositionType; data: FbFormModel; + targetStepId: string; } const moveField = (params: MoveFieldParams) => { - const { field, position, data } = params; + const { field, position, data, targetStepId } = params; const { row, index } = position; const fieldId = typeof field === "string" ? field : field._id; if (!fieldId) { @@ -25,32 +36,33 @@ const moveField = (params: MoveFieldParams) => { return; } + const targetStepLayout = data.steps.find(s => s.id === targetStepId) as FbFormStep; + targetStepLayout.layout = targetStepLayout.layout.filter(row => Boolean(row)); const existingPosition = getFieldPosition({ field: fieldId, - data + data: targetStepLayout }); - if (existingPosition) { - data.layout[existingPosition.row].splice(existingPosition.index, 1); + targetStepLayout.layout[existingPosition.row].splice(existingPosition.index, 1); } // Setting a form field into a new non-existing row. - if (!data.layout[row]) { - data.layout[row] = [fieldId]; + if (!targetStepLayout?.layout[row]) { + targetStepLayout.layout[row] = [fieldId]; return; } // If row exists, we drop the field at the specified index. if (index === null) { // Create a new row with the new field at the given row index, - data.layout.splice(row, 0, [fieldId]); + targetStepLayout.layout.splice(row, 0, [fieldId]); return; } - data.layout[row].splice(index, 0, fieldId); + targetStepLayout.layout[row].splice(index, 0, fieldId); }; export default (params: MoveFieldParams) => { moveField(params); - cleanupEmptyRows(params.data); + cleanupEmptyRows(params); }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts new file mode 100644 index 00000000000..0c532141007 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveFieldBetweenSteps.ts @@ -0,0 +1,74 @@ +import { + FbFormModel, + FbFormModelField, + FbFormStep, + FieldIdType, + FieldLayoutPositionType +} from "~/types"; +import getFieldPosition from "./getFieldPosition"; + +/** + * Remove all rows that have zero fields in it. + * @param data + */ + +const cleanupEmptyRows = (params: MoveFieldBetweenRowsParams): void => { + const { data, targetStepId, sourceStepId } = params; + const targetStep = data.steps.find(s => s.id === targetStepId) as FbFormStep; + const sourceStep = sourceStepId !== undefined && data.steps.find(s => s.id === sourceStepId); + + if (sourceStep) { + sourceStep.layout = sourceStep?.layout.filter(row => row.length > 0); + } + + targetStep.layout = targetStep.layout.filter(row => row.length > 0); +}; + +interface MoveFieldBetweenRowsParams { + field: FieldIdType | FbFormModelField; + position: FieldLayoutPositionType; + data: FbFormModel; + targetStepId: string; + sourceStepId: string; +} + +const moveFieldBetweenSteps = (params: MoveFieldBetweenRowsParams) => { + const { field, position, data, targetStepId, sourceStepId } = params; + const { row, index } = position; + const fieldId = typeof field === "string" ? field : field._id; + if (!fieldId) { + console.log("Missing data when moving field."); + console.log(params); + return; + } + + const targetStepLayout = data.steps.find(s => s.id === targetStepId) as FbFormStep; + const sourceStepLayout = data.steps.find(s => s.id === sourceStepId) as FbFormStep; + const existingPosition = getFieldPosition({ + field: fieldId, + data: sourceStepLayout || targetStepLayout + }); + if (existingPosition) { + sourceStepLayout.layout[existingPosition.row].splice(existingPosition.index, 1); + } + + // Setting a form field into a new non-existing row. + if (!targetStepLayout?.layout[row]) { + targetStepLayout.layout[row] = [fieldId]; + return; + } + + // If row exists, we drop the field at the specified index. + if (index === null) { + // Create a new row with the new field at the given row index, + targetStepLayout.layout.splice(row, 0, [fieldId]); + return; + } + + targetStepLayout.layout[row].splice(index, 0, fieldId); +}; + +export default (params: MoveFieldBetweenRowsParams) => { + moveFieldBetweenSteps(params); + cleanupEmptyRows(params); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts index 9f453235dc0..451ad82e232 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRow.ts @@ -1,9 +1,9 @@ -import { FbFormModel } from "~/types"; +import { FbFormStep } from "~/types"; interface MoveRowParams { source: number; destination: number; - data: FbFormModel; + data: FbFormStep; } export default ({ data, source, destination }: MoveRowParams): void => { data.layout = diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts new file mode 100644 index 00000000000..5fbbbb8f607 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveRowBetweenSteps.ts @@ -0,0 +1,20 @@ +import { FbFormStep, FbFormModel } from "~/types"; + +interface MoveRowParams { + source: number; + destination: number; + data: FbFormModel; + targetStepId: string; + sourceStep: FbFormStep; +} + +export default ({ data, source, destination, targetStepId, sourceStep }: MoveRowParams): void => { + const sourceStepLayout = data.steps.find(step => step.id === sourceStep.id) as FbFormStep; + const targetStepLayout = data.steps.find(step => step.id === targetStepId) as FbFormStep; + + sourceStepLayout.layout = [ + ...sourceStep.layout.slice(0, source), + ...sourceStep.layout.slice(source + 1) + ]; + targetStepLayout.layout.splice(destination, 0, sourceStep?.layout[source] as string[]); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts new file mode 100644 index 00000000000..0f486ffed50 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/functions/moveStep.ts @@ -0,0 +1,24 @@ +import { FbFormStep } from "~/types"; + +interface MoveStepParams { + data: FbFormStep[]; + /* formStep is the step that we are dragging */ + formStep: FbFormStep; + step: FbFormStep; +} + +const moveStep = (params: MoveStepParams) => { + const { step, data, formStep } = params; + + /* step1 is the step that will change it's position with */ + const step1 = data.findIndex((v: FbFormStep) => v.id === step.id); + /* step2 is the step that is being dragged */ + const step2 = data.findIndex((v: FbFormStep) => v.id === formStep.id); + + data.splice(step1, 1, formStep); + data.splice(step2, 1, step); +}; + +export default (params: any) => { + moveStep(params); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts index 13ad0b70b3c..a2729d11c73 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/graphql.ts @@ -79,7 +79,10 @@ export const GET_FORM = gql` fields { ${FIELDS_FIELDS} } - layout + steps { + title + layout + } settings ${SETTINGS_FIELDS} triggers published @@ -118,7 +121,10 @@ export const UPDATE_REVISION = gql` fields { ${FIELDS_FIELDS} } - layout + steps { + title + layout + } settings ${SETTINGS_FIELDS} triggers } diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx index 0fb0bd99464..0d93d380a47 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Context/useFormEditorFactory.tsx @@ -10,7 +10,15 @@ import { UpdateFormRevisionMutationResponse, UpdateFormRevisionMutationVariables } from "./graphql"; -import { getFieldPosition, moveField, moveRow, deleteField } from "./functions"; +import { + getFieldPosition, + moveField, + moveFieldBetweenSteps, + moveRow, + deleteField, + moveStep, + moveRowBetweenSteps +} from "./functions"; import { plugins } from "@webiny/plugins"; import { @@ -20,7 +28,9 @@ import { FbBuilderFieldPlugin, FbFormModel, FbUpdateFormInput, - FbErrorResponse + FbErrorResponse, + FbFormStep, + MoveFieldParams } from "~/types"; import { ApolloClient } from "apollo-client"; import { @@ -30,14 +40,15 @@ import { } from "~/admin/components/FormEditor/Context/index"; import dotProp from "dot-prop-immutable"; import { useSnackbar } from "@webiny/app-admin"; +import { mdbid } from "@webiny/utils"; interface SetDataCallable { (value: FbFormModel): FbFormModel; } -interface MoveFieldParams { - field: FieldIdType | FbFormModelField; - position: FieldLayoutPositionType; +interface MoveStepParams { + step: FbFormStep; + formStep: FbFormStep; } type State = FormEditorProviderContextState; @@ -46,23 +57,39 @@ export interface FormEditor { data: FbFormModel; errors: FormEditorFieldError[] | null; state: State; + addStep: () => void; + deleteStep: (id: string) => void; + updateStep: (title: string, id: string | null) => void; getForm: (id: string) => Promise<{ data: GetFormQueryResponse }>; saveForm: ( data: FbFormModel | null ) => Promise<{ data: FbFormModel | null; error: FbErrorResponse | null }>; setData: (setter: SetDataCallable, saveForm?: boolean) => Promise; getFields: () => FbFormModelField[]; - getLayoutFields: () => FbFormModelField[][]; + getLayoutFields: (targetStepId: string) => FbFormModelField[][]; getField: (query: Partial>) => FbFormModelField | null; getFieldPlugin: ( query: Partial> ) => FbBuilderFieldPlugin | null; - insertField: (field: FbFormModelField, position: FieldLayoutPositionType) => void; + insertField: ( + field: FbFormModelField, + position: FieldLayoutPositionType, + targetStepId: string + ) => void; moveField: (params: MoveFieldParams) => void; - moveRow: (source: number, destination: number) => void; + moveRow: ( + source: number, + destination: number, + targetStepId: string, + sourceStepId?: any + ) => void; + moveStep: (params: MoveStepParams) => void; updateField: (field: FbFormModelField) => void; - deleteField: (field: FbFormModelField) => void; - getFieldPosition: (field: FieldIdType | FbFormModelField) => FieldLayoutPositionType | null; + deleteField: (field: FbFormModelField, targetStepId: string) => void; + getFieldPosition: ( + field: FieldIdType | FbFormModelField, + data: FbFormStep + ) => FieldLayoutPositionType | null; } const extractFieldErrors = (error: FbErrorResponse, form: FbFormModel): FormEditorFieldError[] => { @@ -138,8 +165,20 @@ export const useFormEditorFactory = ( throw new Error(error.message); } + // Here we need to set ids to the steps. + // Because we are not storing them on the API side. + // We need those ids in order to change title for the corresponding step. + // Or when we need to delete corresponding step. + const modifiedData = { + ...data, + steps: data?.steps.map(formStep => ({ + ...formStep, + id: mdbid() + })) + }; + self.setData(() => { - const form = cloneDeep(data) as FbFormModel; + const form = cloneDeep(modifiedData) as FbFormModel; if (!form.settings.layout.renderer) { form.settings.layout.renderer = state.defaultLayoutRenderer; } @@ -150,6 +189,14 @@ export const useFormEditorFactory = ( }, saveForm: async data => { data = data || state.data; + // Removing id fields from steps before sending to the API. + // Because API side does not need to store the id. + data = { + ...data, + steps: data.steps.map(formStep => + pick(formStep, ["title", "layout"]) + ) as unknown as FbFormStep[] + }; if (!data) { return { data: null, @@ -169,7 +216,7 @@ export const useFormEditorFactory = ( * We can safely cast as FbFormModel is FbUpdateFormInput after all, but with some optional values. */ data: pick(data as FbUpdateFormInput, [ - "layout", + "steps", "fields", "name", "settings", @@ -231,9 +278,13 @@ export const useFormEditorFactory = ( /** * Returns complete layout with fields data in it (not just field IDs) */ - getLayoutFields: () => { + getLayoutFields: targetStepId => { + const stepLayout = state.data.steps + .find(v => v.id === targetStepId) + ?.layout.filter(row => Boolean(row)); // Replace every field ID with actual field object. - return state.data.layout.map(row => { + // @ts-ignore + return stepLayout.map(row => { return row .map(id => { return self.getField({ @@ -293,11 +344,50 @@ export const useFormEditorFactory = ( }) || null ); }, + addStep: () => { + self.setData(data => { + data.steps.push({ + id: mdbid(), + title: `Step ${data.steps.length + 1}`, + layout: [] + }); + + return data; + }); + }, + deleteStep: (targetStepId: string) => { + const stepFields = self.getLayoutFields(targetStepId).flat(1); + const deleteStepFields = (data: FbFormModel) => { + const stepLayout = stepFields.map(field => + deleteField({ field, data, targetStepId }) + ); + return stepLayout; + }; + + self.setData(data => { + const deleteStepIndex = data.steps.findIndex(step => step.id === targetStepId); + deleteStepFields(data); + data.steps.splice(deleteStepIndex, 1); + + return data; + }); + }, + updateStep: (stepTitle, id) => { + if (!stepTitle) { + showSnackbar("Step title cannot be empty"); + } else { + self.setData(data => { + const stepIndex = data.steps.findIndex(step => step.id === id); + data.steps[stepIndex].title = stepTitle; + return data; + }); + } + }, /** * Inserts a new field into the target position. */ - insertField: (data, position) => { + insertField: (data, position, targetStepId) => { const field = cloneDeep(data); if (!field._id) { field._id = shortid.generate(); @@ -323,7 +413,8 @@ export const useFormEditorFactory = ( moveField({ field, position, - data + data, + targetStepId }); // We are dropping a new field at the specified index. @@ -334,25 +425,69 @@ export const useFormEditorFactory = ( /** * Moves field to the given target position. */ - moveField: ({ field, position }) => { + moveField: ({ field, position, targetStepId, sourceStepId }) => { + // If sourceStepId ("source step" is the step from which we take a field) is different, + // to a targetStepId ("target step" is the step in which we want to move a field) then we need to use function "moveFieldBetweenRows", + // if targetStepId equals to sourceStepId then it means that we are moving field inside of the same step. + if (targetStepId === sourceStepId) { + self.setData(data => { + moveField({ + field, + position, + data, + targetStepId + }); + return data; + }); + } else { + self.setData(data => { + moveFieldBetweenSteps({ + field, + position, + data, + targetStepId, + sourceStepId + }); + return data; + }); + } + }, + moveStep: ({ step, formStep }) => { self.setData(data => { - moveField({ - field, - position, - data + moveStep({ + step, + formStep, + data: data.steps }); + return data; }); }, - /** * Moves row to a destination row. */ - moveRow: (source, destination) => { - self.setData(data => { - moveRow({ data, source, destination }); - return data; - }); + moveRow: (source, destination, targetStepId, sourceStep) => { + if (targetStepId === sourceStep.id) { + self.setData(data => { + moveRow({ + data: data.steps.find(v => v.id === targetStepId) as FbFormStep, + source, + destination + }); + return data; + }); + } else { + self.setData(data => { + moveRowBetweenSteps({ + data, + source, + destination, + targetStepId, + sourceStep + }); + return data; + }); + } }, /** @@ -374,9 +509,9 @@ export const useFormEditorFactory = ( /** * Deletes a field (both from the list of field and the layout). */ - deleteField: field => { + deleteField: (field, targetStepId) => { self.setData(data => { - deleteField({ field, data }); + deleteField({ field, data, targetStepId }); return data; }); }, @@ -384,8 +519,8 @@ export const useFormEditorFactory = ( /** * Returns row / index position for given field. */ - getFieldPosition: field => { - return getFieldPosition({ field, data: self.data }); + getFieldPosition: (field, data) => { + return getFieldPosition({ field, data }); } }; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx index 425c7e12f60..dc0b41bd4f2 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Draggable.tsx @@ -2,6 +2,7 @@ import React, { ReactElement } from "react"; import { useDrag, DragPreviewImage, ConnectDragSource } from "react-dnd"; import { DragSourceMonitor } from "react-dnd/lib/interfaces/monitors"; import { DragObjectWithType } from "react-dnd/lib/interfaces/hooksApi"; +import { FbFormStep } from "~/types"; const emptyImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; @@ -11,11 +12,12 @@ export type DraggableChildrenFunction = (params: { }) => ReactElement; interface BeginDragProps { - ui?: "row" | "field"; + ui?: "row" | "field" | "step"; pos?: { row: number; index?: number; }; + formStep?: FbFormStep; name?: string; } diff --git a/packages/app-form-builder/src/admin/components/FormEditor/DropZone/Center.tsx b/packages/app-form-builder/src/admin/components/FormEditor/DropZone/Center.tsx index 4fb7d1cd2ee..423a1dd1970 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/DropZone/Center.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/DropZone/Center.tsx @@ -4,8 +4,9 @@ import { Droppable, OnDropCallable } from "./../Droppable"; interface ContainerProps { isOver: boolean; + isDragging: boolean; } -const Container = styled("div")(({ isOver }: ContainerProps) => ({ +const Container = styled("div")(({ isOver, isDragging }: ContainerProps) => ({ backgroundColor: "transparent", boxSizing: "border-box", height: "100%", @@ -13,9 +14,9 @@ const Container = styled("div")(({ isOver }: ContainerProps) => ({ position: "relative", userSelect: "none", width: "100%", - border: isOver - ? "2px dashed var(--mdc-theme-primary)" - : "2px dashed var(--mdc-theme-secondary)", + borderWidth: isDragging ? "4px" : "2px", + borderStyle: "dashed", + borderColor: isOver ? "var(--mdc-theme-primary)" : "var(--mdc-theme-secondary)", opacity: 1 })); @@ -42,13 +43,13 @@ export interface CenterProps { export const Center: React.FC = ({ onDrop, children }) => { return ( - {({ isOver, drop }) => ( + {({ isOver, isDragging, drop }) => (
- + {children}
diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx index cacd384d3e5..62c87d1d836 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Droppable.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { ConnectDropTarget, DragObjectWithType, useDrop } from "react-dnd"; -import { FieldLayoutPositionType } from "~/types"; +import { FbFormStep, FieldLayoutPositionType } from "~/types"; export type DroppableChildrenFunction = (params: { isDragging: boolean; @@ -25,12 +25,27 @@ export interface IsVisibleCallableParams { isDragging: boolean; ui: string; pos?: Partial; + formStep?: FbFormStep; } export interface IsVisibleCallable { (params: IsVisibleCallableParams): boolean; } +/* + We need to extend DragObjectWithType type because it does not support fields, + that we set through "beginDrag". + * "ui" propetry gives us information about the Entity that we are moving. + "Entity" can be step, field, row or custom. "Entity" will be custom in case we are moving field from a "Custom Field" menu. + * "name" property contains the type of the field, it can be text, number or one of the available fields. + * "pos" propety contains info about Entity position that we are moving + pos can be undefined in case we are moving field from a "Custom Field" menu. +*/ +export interface DragObjectWithFieldInfo extends DragObjectWithType { + ui: string; + name: string; + pos?: Partial; +} export interface OnDropCallable { - (item: DragObjectWithType): DroppableDropResult | undefined; + (item: DragObjectWithFieldInfo): DroppableDropResult | undefined; } export interface DroppableProps { type?: string; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx index 5bea8e2be2f..ed2153bfb00 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/EditTab.tsx @@ -1,19 +1,17 @@ -import React, { useCallback, useState } from "react"; -import { Icon } from "@webiny/ui/Icon"; -import cloneDeep from "lodash/cloneDeep"; -import { Center, Vertical, Horizontal } from "../../DropZone"; +import React, { useState } from "react"; +import { Horizontal } from "../../DropZone"; import Draggable from "../../Draggable"; -import EditFieldDialog from "./EditFieldDialog"; -import Field from "./Field"; -import { ReactComponent as HandleIcon } from "../../../../icons/round-drag_indicator-24px.svg"; -import { rowHandle, EditContainer, fieldHandle, fieldContainer, Row, RowContainer } from "./Styled"; +import { EditContainer, RowContainer } from "./Styled"; import { FormEditorFieldError, useFormEditor } from "../../Context"; -import { FbFormModelField, FieldLayoutPositionType } from "~/types"; -import { i18n } from "@webiny/app/i18n"; +import { FbFormStep } from "~/types"; import { Alert } from "@webiny/ui/Alert"; import styled from "@emotion/styled"; -const t = i18n.namespace("FormsApp.Editor.EditTab"); +import { IconButton } from "@webiny/ui/Button"; +import { ReactComponent as AddIcon } from "@material-design-icons/svg/outlined/add_circle_outline.svg"; + +import { FormStep } from "./FormStep/FormStep"; +import { EditFormStepDialog } from "./FormStep/EditFormStepDialog"; const Block = styled("span")({ display: "block" @@ -62,6 +60,23 @@ const FieldErrors: React.FC = ({ errors }) => { ); }; +const AddStepBtn = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-top: 25px; + text-transform: uppercase; + cursor: pointer; +`; + +const RowContainerWrapper = styled.div` + & .css-1uf2zda-RowContainer { + &:last-child { + margin-bottom: 25px !important; + } + } +`; + export const EditTab: React.FC = () => { const { getLayoutFields, @@ -72,201 +87,146 @@ export const EditTab: React.FC = () => { errors, moveField, moveRow, - getFieldPlugin + getFieldPlugin, + addStep, + deleteStep, + updateStep, + moveStep } = useFormEditor(); - const [editingField, setEditingField] = useState(null); - const [dropTarget, setDropTarget] = useState(null); - const editField = useCallback((field: FbFormModelField | null) => { - if (!field) { - setEditingField(null); - return; - } - setEditingField(cloneDeep(field)); - }, []); - - // TODO @ts-refactor figure out source type - const handleDropField = useCallback( - (source: any, position: FieldLayoutPositionType): void => { - const { pos, name, ui } = source; + const [isEditStep, setIsEditStep] = useState<{ isOpened: boolean; id: string | null }>({ + isOpened: false, + id: null + }); - if (name === "custom") { - /** - * We can cast because field is empty in the start - */ - editField({} as FbFormModelField); - setDropTarget(position); - return; - } + const stepTitle = data.steps.find(step => step.id === isEditStep.id)?.title || ""; - if (ui === "row") { - // Reorder rows. - // Reorder logic is different depending on the source and target position. - moveRow(pos.row, position.row); - return; - } + const handleStepMove = (source: any, step: FbFormStep): void => { + const { pos, formStep } = source; - // If source pos is set, we are moving an existing field. - if (pos) { - if (pos.index === null) { - console.log("Tried to move Form Field but its position index is null."); - console.log(source); - return; - } - const fieldId = data.layout[pos.row][pos.index]; - moveField({ - field: fieldId, - position - }); + if (pos) { + if (pos.index === null) { return; } + } - // Find field plugin which handles the dropped field type "name". - const plugin = getFieldPlugin({ name }); - if (!plugin) { - return; - } - insertField(plugin.field.createField(), position); - }, - [data] - ); + moveStep({ + step, + formStep + }); + }; + + // This function will render drop zones on the top of the step, + // if steps are locatted above "source" ("source" step is the step that we move). + const renderTopDropZone = (sourceStepId: string | undefined, targetStepId: string) => { + if (!sourceStepId) { + return false; + } + const stepsIds = data.steps.reduce( + (prevVal, currVal) => [...prevVal, currVal.id], + [] as string[] + ); + + return stepsIds.slice(0, stepsIds.indexOf(sourceStepId)).includes(targetStepId); + }; + + // This function will render drop zones on the top of the step, + // if steps are locatted below "source" ("source" step is the step that we move). + const renderBottomDropZone = (sourceStepId: string | undefined, targetStepId: string) => { + if (!sourceStepId) { + return false; + } + const stepsIds = data.steps.reduce( + (prevVal, currVal) => [...prevVal, currVal.id], + [] as string[] + ); - const fields = getLayoutFields(); + return stepsIds.slice(stepsIds.indexOf(sourceStepId)).includes(targetStepId); + }; return ( - {fields.length === 0 && ( -
{ - handleDropField(item, { - row: 0, - index: 0 - }); - return undefined; - }} + {data.steps.map((formStep: FbFormStep, index: number) => ( + - {t`Drop your first field here`} -
- )} - - {fields.map((row, index) => ( - - {( - { - drag, - isDragging - } /* RowContainer start - includes drag handle, drop zones and the Row itself. */ - ) => ( - -
- } /> -
- { - handleDropField(item, { - row: index, - index: null - }); - return undefined; + {({ drag, isDragging }) => ( + + - {/* Row start - includes field drop zones and fields */} - - {row.map((field, fieldIndex) => ( - +
+ deleteStep(formStep.id)} + onEdit={() => { + setIsEditStep({ + isOpened: true, + id: formStep.id + }); }} - > - {({ drag }) => ( -
- { - handleDropField(item, { - row: index, - index: fieldIndex - }); - return undefined; - }} - isVisible={item => - item.ui === "field" && - (row.length < 4 || item?.pos?.row === index) - } - /> - -
- -
- - {/* Field end */} - {fieldIndex === row.length - 1 && ( - - item.ui === "field" && - (row.length < 4 || - item?.pos?.row === index) - } - onDrop={item => { - handleDropField(item, { - row: index, - index: fieldIndex + 1 - }); - return undefined; - }} - /> - )} -
- )} - - ))} - - {/* Row end */} - {index === fields.length - 1 && ( + deleteStepDisabled={data.steps.length <= 1} + moveRow={moveRow} + moveField={moveField} + getFieldPlugin={getFieldPlugin} + insertField={insertField} + getLayoutFields={getLayoutFields} + updateField={updateField} + deleteField={deleteField} + data={data} + /> +
+ { + handleStepMove(item, formStep); + return undefined; + }} + isVisible={item => { + return ( + item.ui === "step" && + renderTopDropZone(item?.formStep?.id, formStep.id) + ); + }} + /> { - handleDropField(item, { - row: index + 1, - index: null - }); + handleStepMove(item, formStep); return undefined; }} + isVisible={item => { + return ( + item.ui === "step" && + renderBottomDropZone(item?.formStep?.id, formStep.id) + ); + }} /> +
+ {data.steps[data.steps.length - 1].id === formStep.id && ( + + } /> + Add new step + )} -
+ )}
))} - - { - editField(null); - }} - onSubmit={initialData => { - const data = initialData as unknown as FbFormModelField; - - if (data._id) { - updateField(data); - } else if (!dropTarget) { - console.log("Missing drop target on EditFieldDialog submit."); - } else { - insertField(data, dropTarget); - } - editField(null); - }} +
); diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx new file mode 100644 index 00000000000..3e31000b531 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/EditFormStepDialog.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import styled from "@emotion/styled"; + +import { Dialog as BaseDialog } from "@webiny/ui/Dialog"; +import { Form, FormOnSubmit } from "@webiny/form"; +import { Input } from "@webiny/ui/Input"; +import { ButtonPrimary, ButtonSecondary } from "@webiny/ui/Button"; +import { validation } from "@webiny/validation"; + +const EditStepDialog = styled(BaseDialog)` + font-size: 1.4rem; + color: #fff; + font-weight: 600; + + & .mdc-dialog__surface { + width: 575px; + } +`; + +const DialogHeader = styled.div` + height: 30px; + background-color: #00ccb0; + padding: 20px 20px; + + & span { + vertical-align: middle; + } +`; + +const DialogBody = styled.div` + padding: 20px 20px; + min-height: 75px; +`; + +const DialogActions = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + padding: 20px 20px; + border-top: 1px solid rgba(212, 212, 212, 0.5); + + & .webiny-ui-button--primary { + margin-left: 20px; + } +`; + +export interface DialogProps { + isEditStep: { + isOpened: boolean; + id: string | null; + }; + stepTitle: string; + setIsEditStep: (params: { isOpened: boolean; id: string | null }) => void; + updateStep: (title: string, id: string | null) => void; +} + +type SubmitData = { title: string }; + +export const EditFormStepDialog = ({ + isEditStep, + stepTitle, + setIsEditStep, + updateStep +}: DialogProps) => { + const onSubmit: FormOnSubmit = (_, form) => { + updateStep(form.data.title, isEditStep.id); + setIsEditStep({ isOpened: false, id: null }); + }; + return ( + <> + + setIsEditStep({ + isOpened: false, + id: null + }) + } + > +
+ {({ Bind, submit }) => ( + <> + + Change Step Title + + + + + + + + setIsEditStep({ isOpened: false, id: null })} + > + Cancel + + Save + + + )} +
+
+ + ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx new file mode 100644 index 00000000000..7ad6ab96db1 --- /dev/null +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/FormStep/FormStep.tsx @@ -0,0 +1,310 @@ +import React, { useCallback, useState } from "react"; +import cloneDeep from "lodash/cloneDeep"; + +import { + FbFormModelField, + FieldLayoutPositionType, + FbBuilderFieldPlugin, + MoveFieldParams, + FbFormModel, + FbFormStep +} from "~/types"; +import Draggable from "../../../Draggable"; +import EditFieldDialog from "../EditFieldDialog"; +import Field from "../Field"; +import { + rowHandle, + fieldHandle, + fieldContainer, + Row, + RowContainer, + StyledAccordion, + StyledAccordionItem +} from "../Styled"; + +import { Icon } from "@webiny/ui/Icon"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; +import { ReactComponent as EditIcon } from "@material-design-icons/svg/outlined/edit.svg"; +import { ReactComponent as HandleIcon } from "../../../../../icons/round-drag_indicator-24px.svg"; +import { Center, Vertical, Horizontal } from "../../../DropZone"; + +import { i18n } from "@webiny/app/i18n"; +const t = i18n.namespace("FormsApp.Editor.EditTab"); + +export const FormStep = ({ + title, + deleteStepDisabled, + data, + formStep, + onDelete, + onEdit, + moveRow, + moveField, + getFieldPlugin, + insertField, + getLayoutFields, + updateField, + deleteField +}: { + title: string; + deleteStepDisabled: boolean; + data: FbFormModel; + formStep: FbFormStep; + onDelete: () => void; + onEdit: () => void; + moveRow: ( + source: number, + destination: number, + targetStepId: string, + sourceStep: FbFormStep + ) => void; + moveField: (params: MoveFieldParams) => void; + getFieldPlugin: ({ name }: { name: string }) => FbBuilderFieldPlugin | null; + insertField: ( + field: FbFormModelField, + position: FieldLayoutPositionType, + stepId: string + ) => void; + getLayoutFields: (stepId: string) => FbFormModelField[][]; + updateField: (field: FbFormModelField) => void; + deleteField: (field: FbFormModelField, stepId: string) => void; +}) => { + const [editingField, setEditingField] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + + const editField = useCallback((field: FbFormModelField | null) => { + if (!field) { + setEditingField(null); + return; + } + setEditingField(cloneDeep(field)); + }, []); + + // TODO @ts-refactor figure out source type + const handleDropField = useCallback( + (source: any, position: FieldLayoutPositionType): void => { + const { pos, name, ui, formStep: sourceStep } = source; + + if (name === "custom") { + /** + * We can cast because field is empty in the start + */ + editField({} as FbFormModelField); + setDropTarget(position); + return; + } + + if (ui === "row") { + // Reorder rows. + // Reorder logic is different depending on the source and target position. + // pos.formStep is a source step from which we move row. + // formStep is a target step in which we move row. + moveRow(pos.row, position.row, formStep.id, sourceStep); + return; + } + + // If source pos is set, we are moving an existing field. + if (pos) { + if (pos.index === null) { + console.log("Tried to move Form Field but its position index is null."); + console.log(source); + return; + } + // Here we are getting field from the source step ("source step" is a step from which we take a field) + const fieldId = sourceStep.layout[pos.row][pos.index]; + moveField({ + field: fieldId, + position, + targetStepId: formStep.id, + sourceStepId: sourceStep.id + }); + return; + } + + // Find field plugin which handles the dropped field type "name". + const plugin = getFieldPlugin({ name }); + if (!plugin) { + return; + } + insertField(plugin.field.createField(), position, formStep.id); + }, + [data] + ); + + const fields = getLayoutFields(formStep.id); + + return ( +
+ +
+ } /> +
+ + } onClick={onEdit} /> + } + onClick={onDelete} + disabled={deleteStepDisabled} + /> + + } + > + {fields.length === 0 && ( +
{ + // We don't want to drop steps inside of steps + if (item.ui === "step") { + return undefined; + } + handleDropField(item, { + row: 0, + index: 0 + }); + return undefined; + }} + > + {t`Drop your first field here`} +
+ )} + {fields.map((row, index) => ( + + {( + { + drag, + isDragging + } /* RowContainer start - includes drag handle, drop zones and the Row itself. */ + ) => ( + +
+ } /> +
+ { + handleDropField(item, { + row: index, + index: null + }); + return undefined; + }} + isVisible={item => item.ui !== "step"} + /> + {/* Row start - includes field drop zones and fields */} + + {row.map((field, fieldIndex) => ( + + {({ drag }) => ( +
+ { + handleDropField(item, { + row: index, + index: fieldIndex + }); + return undefined; + }} + isVisible={item => + item.ui === "field" && + (row.length < 4 || + item?.pos?.row === index) + } + /> + +
+ + deleteField(field, formStep.id) + } + /> +
+ + {/* Field end */} + {fieldIndex === row.length - 1 && ( + + item.ui === "field" && + (row.length < 4 || + item?.pos?.row === index) + } + onDrop={item => { + handleDropField(item, { + row: index, + index: fieldIndex + 1 + }); + return undefined; + }} + /> + )} +
+ )} +
+ ))} +
+ {/* Row end */} + {index === fields.length - 1 && ( + { + handleDropField(item, { + row: index + 1, + index: null + }); + return undefined; + }} + isVisible={item => item.ui !== "step"} + /> + )} +
+ )} +
+ ))} + { + editField(null); + }} + onSubmit={initialData => { + const data = initialData as unknown as FbFormModelField; + + if (data._id) { + updateField(data); + } else if (!dropTarget) { + console.log("Missing drop target on EditFieldDialog submit."); + } else { + insertField(data, dropTarget, formStep.id); + } + editField(null); + }} + /> +
+
+
+ ); +}; diff --git a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts index e467d031f45..de78b57d532 100644 --- a/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts +++ b/packages/app-form-builder/src/admin/components/FormEditor/Tabs/EditTab/Styled.ts @@ -1,22 +1,38 @@ import { css } from "emotion"; import styled from "@emotion/styled"; +import { Accordion, AccordionItem } from "@webiny/ui/Accordion"; + +export const StyledAccordion = styled(Accordion)` + background: var(--mdc-theme-background); + box-shadow: none; +`; + +export const StyledAccordionItem = styled(AccordionItem)` + & .webiny-ui-accordion-item__content { + background: white; + } +`; export const EditContainer = styled("div")({ padding: 40, position: "relative" }); -export const RowContainer = styled("div")({ - position: "relative", - display: "flex", - flexDirection: "column", - marginBottom: 25, - borderRadius: 2, - backgroundColor: "var(--mdc-theme-surface)", - border: "1px solid var(--mdc-theme-on-background)", - boxShadow: - "var(--mdc-theme-on-background) 1px 1px 1px, var(--mdc-theme-on-background) 1px 1px 2px" -}); +export const RowContainer = styled("div")` + position: relative; + display: flex; + flex-direction: column; + margin-bottom: 25px; + + &:last-child { + margin-bottom: 0px; + } + + border-radius: 2px; + background-color: var(--mdc-theme-surface); + box-shadow: var(--mdc-theme-on-background) 1px 1px 1px, + var(--mdc-theme-on-background) 1px 1px 2px; +`; export const Row = styled("div")({ display: "flex", @@ -24,7 +40,10 @@ export const Row = styled("div")({ backgroundColor: "var(--mdc-theme-surface)", paddingLeft: 40, paddingRight: 10, - position: "relative" + position: "relative", + // We need this because on the smaller screens fourth field in the row shifts out of the row container, + // so it breaks the layout. + overflowX: "auto" }); export const fieldContainer = css({ diff --git a/packages/app-form-builder/src/admin/graphql.ts b/packages/app-form-builder/src/admin/graphql.ts index 99830b15c7e..c38b2678d62 100644 --- a/packages/app-form-builder/src/admin/graphql.ts +++ b/packages/app-form-builder/src/admin/graphql.ts @@ -203,7 +203,6 @@ export const LIST_FORM_SUBMISSIONS = gql` id name version - layout fields { _id fieldId @@ -214,6 +213,10 @@ export const LIST_FORM_SUBMISSIONS = gql` value } } + steps { + title + layout + } } } meta { diff --git a/packages/app-form-builder/src/admin/plugins/editor/formFields/components/OptionsList.tsx b/packages/app-form-builder/src/admin/plugins/editor/formFields/components/OptionsList.tsx index 7f78b75a876..ad03e9736ed 100644 --- a/packages/app-form-builder/src/admin/plugins/editor/formFields/components/OptionsList.tsx +++ b/packages/app-form-builder/src/admin/plugins/editor/formFields/components/OptionsList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useState } from "react"; import { css } from "emotion"; import styled from "@emotion/styled"; import camelCase from "lodash/camelCase"; @@ -156,15 +156,12 @@ const OptionsList: React.FC = ({ form, multiple, otherOption } value: optionsValue, onChange: setOptionsValue } = bind; - const onSubmit = useCallback( - (data: FieldOption): void => { - const newValue = [...optionsValue]; - newValue.splice(editOption.index as number, 1, data); - setOptionsValue(newValue); - clearEditOption(); - }, - [optionsValue, setOptionsValue] - ); + const onSubmit = (data: FieldOption): void => { + const newValue = [...optionsValue]; + newValue.splice(editOption.index as number, 1, data); + setOptionsValue(newValue); + clearEditOption(); + }; return ( <>
Options
diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/FormSubmissionDialog.tsx b/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/FormSubmissionDialog.tsx index b79806f64c1..557d1d94b22 100644 --- a/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/FormSubmissionDialog.tsx +++ b/packages/app-form-builder/src/admin/plugins/formDetails/formSubmissions/FormSubmissionsList/FormSubmissionDialog.tsx @@ -137,41 +137,45 @@ const FormSubmissionDialog: React.FC = ({ formSubmiss
- {formSubmission.form.layout.map(row => { - return row.map(id => { - const field = formSubmission.form.fields.find( - field => field._id === id - ); - if (!field) { - return null; - } - - return ( -
- {field.label}: - - {field.type === "textarea" ? ( -
-                                                        {renderFieldValueLabel(
+                            {formSubmission.form.steps.map(step => {
+                                return step.layout.map(row => {
+                                    return row.map(id => {
+                                        const field = formSubmission.form.fields.find(
+                                            field => field._id === id
+                                        );
+                                        if (!field) {
+                                            return null;
+                                        }
+
+                                        return (
+                                            
+ + {field.label}:{" "} + + + {field.type === "textarea" ? ( +
+                                                            {renderFieldValueLabel(
+                                                                field,
+                                                                formSubmission.data[field.fieldId]
+                                                            )}
+                                                        
+ ) : ( + renderFieldValueLabel( field, formSubmission.data[field.fieldId] - )} -
- ) : ( - renderFieldValueLabel( - field, - formSubmission.data[field.fieldId] - ) - )} -
-
- ); + ) + )} + +
+ ); + }); }); })} diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/Header.tsx b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/Header.tsx index 4aa330641d3..c9430972808 100644 --- a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/Header.tsx +++ b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/Header.tsx @@ -2,12 +2,7 @@ import React from "react"; import { css } from "emotion"; import { Typography } from "@webiny/ui/Typography"; import { Grid, Cell } from "@webiny/ui/Grid"; -import { - PublishRevision, - DeleteRevision, - EditRevision, - RevisionSelector -} from "./HeaderComponents"; +import { PublishRevision, EditRevision, DeleteForm, RevisionSelector } from "./HeaderComponents"; import { FbFormDetailsPluginRenderParams, FbRevisionModel } from "~/types"; const headerTitle = css({ @@ -54,7 +49,7 @@ const Header: React.FC = props => { - + )} diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/DeleteForm.tsx b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/DeleteForm.tsx new file mode 100644 index 00000000000..de57b9d491c --- /dev/null +++ b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/DeleteForm.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { useRouter } from "@webiny/react-router"; +import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { IconButton } from "@webiny/ui/Button"; +import { Tooltip } from "@webiny/ui/Tooltip"; +import { ConfirmationDialog } from "@webiny/ui/ConfirmationDialog"; +import { ReactComponent as DeleteIcon } from "../../../../icons/delete.svg"; +import * as queries from "../../../../graphql"; +import { removeFormFromListCache } from "~/admin/views/cache"; +import { usePermission } from "~/hooks/usePermission"; +import { FbRevisionModel } from "~/types"; + +interface DeleteRevisionProps { + form: FbRevisionModel; + revision: FbRevisionModel; +} +const DeleteForm: React.FC = ({ form, revision }) => { + const { showSnackbar } = useSnackbar(); + const client = useApolloClient(); + const { history } = useRouter(); + const { canDelete } = usePermission(); + + // Render nothing is user doesn't have required permission. + if (!canDelete(form)) { + return null; + } + + const message = "You are about to delete this form. Are you sure want to continue?"; + + return ( + + + {({ showConfirmation }) => ( + } + onClick={() => + showConfirmation(async () => { + await client.mutate({ + mutation: queries.DELETE_FORM, + variables: { id: revision.id }, + update: (cache, { data }) => { + const { error } = data.formBuilder.deleteForm; + if (error) { + showSnackbar(error.message); + return; + } + + showSnackbar(`Form was deleted successfully!`); + + removeFormFromListCache(cache, form); + + // Redirect + history.push("/form-builder/forms"); + return; + } + }); + }) + } + /> + )} + + + ); +}; + +export default DeleteForm; diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/DeleteRevision.tsx b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/DeleteRevision.tsx deleted file mode 100644 index 22a10485b37..00000000000 --- a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/DeleteRevision.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from "react"; -import { useApolloClient } from "@apollo/react-hooks"; -import { useRouter } from "@webiny/react-router"; -import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; -import { IconButton } from "@webiny/ui/Button"; -import { Tooltip } from "@webiny/ui/Tooltip"; -import { ConfirmationDialog } from "@webiny/ui/ConfirmationDialog"; -import { ReactComponent as DeleteIcon } from "../../../../icons/delete.svg"; -import * as queries from "../../../../graphql"; -import { - removeFormFromListCache, - removeRevisionFromFormCache, - updateLatestRevisionInListCache -} from "~/admin/views/cache"; -import { usePermission } from "~/hooks/usePermission"; -import { FbRevisionModel } from "~/types"; - -interface DeleteRevisionProps { - revisions: FbRevisionModel[]; - form: FbRevisionModel; - revision: FbRevisionModel; - selectRevision: (revision: FbRevisionModel) => void; -} -const DeleteRevision: React.FC = ({ - revisions, - form, - revision, - selectRevision -}) => { - const { showSnackbar } = useSnackbar(); - const client = useApolloClient(); - const { history } = useRouter(); - const { canDelete } = usePermission(); - - // Render nothing is user doesn't have required permission. - if (!canDelete(form)) { - return null; - } - - let message = "You are about to delete this form revision, are you sure want to continue?"; - const lastRevision = revisions.length === 1; - if (lastRevision) { - message = "You are about to delete this form. Are you sure want to continue?"; - } - - return ( - - - {({ showConfirmation }) => ( - } - onClick={() => - showConfirmation(async () => { - await client.mutate({ - mutation: lastRevision - ? queries.DELETE_FORM - : queries.DELETE_REVISION, - variables: lastRevision - ? { id: revision.id } - : { revision: revision.id }, - update: (cache, { data }) => { - const { error } = data.formBuilder.deleteForm; - if (error) { - showSnackbar(error.message); - return; - } - - showSnackbar( - `${ - lastRevision ? "Form" : "Revision" - } was deleted successfully!` - ); - - if (lastRevision) { - removeFormFromListCache(cache, form); - - // Redirect - history.push("/form-builder/forms"); - return; - } - - // We have other revisions, update form's cache - const revisions = removeRevisionFromFormCache( - cache, - form, - revision - ); - - updateLatestRevisionInListCache(cache, revisions[0]); - - // Redirect to the first revision in the list of all form revisions. - const firstRevision = revisions[0]; - selectRevision(firstRevision); - if (revision.id !== form.id) { - return; - } - history.push( - `/form-builder/forms?id=${encodeURIComponent( - firstRevision.id - )}` - ); - } - }); - }) - } - /> - )} - - - ); -}; - -export default DeleteRevision; diff --git a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/index.ts b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/index.ts index 11ad10a12ce..f6045a60a1c 100644 --- a/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/index.ts +++ b/packages/app-form-builder/src/admin/plugins/formDetails/previewContent/HeaderComponents/index.ts @@ -1,6 +1,6 @@ import RevisionSelector from "./RevisionSelector"; import PublishRevision from "./PublishRevision"; import EditRevision from "./EditRevision"; -import DeleteRevision from "./DeleteRevision"; +import DeleteForm from "./DeleteForm"; -export { RevisionSelector, EditRevision, DeleteRevision, PublishRevision }; +export { RevisionSelector, EditRevision, PublishRevision, DeleteForm }; diff --git a/packages/app-form-builder/src/admin/plugins/formsDataList/ExportButton/useExportFormDialog.tsx b/packages/app-form-builder/src/admin/plugins/formsDataList/ExportButton/useExportFormDialog.tsx index b9047dfb16a..415e3978027 100644 --- a/packages/app-form-builder/src/admin/plugins/formsDataList/ExportButton/useExportFormDialog.tsx +++ b/packages/app-form-builder/src/admin/plugins/formsDataList/ExportButton/useExportFormDialog.tsx @@ -89,7 +89,11 @@ const ExportFormDialogMessage: React.FC = ({ exportUrl })
- + {exportUrl} @@ -128,6 +132,7 @@ interface UseExportFormDialog { showExportFormInitializeDialog: (props: ExportFormsDialogProps) => void; hideDialog: () => void; } + const useExportFormDialog = (): UseExportFormDialog => { const { showDialog, hideDialog } = useDialog(); diff --git a/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx b/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx index 90de762a56c..1534cac75d5 100644 --- a/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx +++ b/packages/app-form-builder/src/admin/views/Forms/FormsDataList.tsx @@ -290,7 +290,11 @@ const FormsDataList: React.FC = props => { {data.map(form => { const name = form.createdBy.displayName; return ( - + multiSelectProps.multiSelect(form)} @@ -319,13 +323,19 @@ const FormsDataList: React.FC = props => { {upperFirst(form.status)} (v{form.version}) - {canUpdate(form) && } + {canUpdate(form) && ( + + )} {canDelete(form) && ( {({ showConfirmation }) => ( = props => { history.push("/form-builder/forms"); }) } + data-testid="delete-form-action" /> )} diff --git a/packages/app-form-builder/src/admin/views/Settings/FormsSettings.tsx b/packages/app-form-builder/src/admin/views/Settings/FormsSettings.tsx index fdba8eb75bc..72462967e98 100644 --- a/packages/app-form-builder/src/admin/views/Settings/FormsSettings.tsx +++ b/packages/app-form-builder/src/admin/views/Settings/FormsSettings.tsx @@ -32,7 +32,7 @@ const FormsSettings: React.FC = () => { const [updateSettings, updateSettingsMutation] = useMutation< UpdateFormSettingsMutationResponse, UpdateFormSettingsMutationVariables - >(graphql.mutation); + >(graphql.mutation, { refetchQueries: [{ query: graphql.query }] }); const mutationInProgress = updateSettingsMutation?.loading; return ( diff --git a/packages/app-form-builder/src/components/Form/FormRender.tsx b/packages/app-form-builder/src/components/Form/FormRender.tsx index 615f56624b5..971e649ebf4 100644 --- a/packages/app-form-builder/src/components/Form/FormRender.tsx +++ b/packages/app-form-builder/src/components/Form/FormRender.tsx @@ -1,6 +1,6 @@ import { plugins } from "@webiny/plugins"; import cloneDeep from "lodash/cloneDeep"; -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useApolloClient } from "@apollo/react-hooks"; import { createReCaptchaComponent, createTermsOfServiceComponent } from "./components"; import { @@ -43,6 +43,12 @@ interface FieldValidator { const FormRender: React.FC = props => { const client = useApolloClient(); const data = props.data || ({} as FbFormModel); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + + const [layoutRenderKey, setLayoutRenderKey] = useState(new Date().getTime().toString()); + const resetLayoutRenderKey = useCallback(() => { + setLayoutRenderKey(new Date().getTime().toString()); + }, []); useEffect((): void => { if (!data.id) { @@ -55,6 +61,13 @@ const FormRender: React.FC = props => { }); }, [data.id]); + // We need this useEffect in case when user has deleted a step and he was on that step on the preview tab, + // so it won't trigger an error when we trying to view the step that we have deleted, + // we will simpy change currentStep to the first step. + useEffect(() => { + setCurrentStepIndex(0); + }, [data.steps.length]); + const reCaptchaResponseToken = useRef(""); const termsOfServiceAccepted = useRef(false); @@ -62,8 +75,28 @@ const FormRender: React.FC = props => { return null; } + const goToNextStep = () => { + setCurrentStepIndex(prevStep => (prevStep += 1)); + }; + + const goToPreviousStep = () => { + setCurrentStepIndex(prevStep => (prevStep -= 1)); + }; + const formData: FbFormModel = cloneDeep(data); - const { layout, fields, settings } = formData; + const { fields, settings, steps } = formData; + + // Check if the form is a multi step. + const isMultiStepForm = formData.steps.length > 1; + + const isFirstStep = isMultiStepForm && currentStepIndex === 0; + const isLastStep = isMultiStepForm && currentStepIndex === steps.length - 1; + + // We need this check in case we deleted last step and at the same time we were previewing it. + const currentStep = + steps[currentStepIndex] === undefined + ? steps[formData.steps.length - 1] + : steps[currentStepIndex]; const getFieldById = (id: string): FbFormModelField | null => { return fields.find(field => field._id === id) || null; @@ -73,8 +106,11 @@ const FormRender: React.FC = props => { return fields.find(field => field.fieldId === id) || null; }; - const getFields = (): FormRenderFbFormModelField[][] => { - const fieldLayout = cloneDeep(layout); + // We need to have "stepIndex" prop in order to get corresponding fields for the current step. + const getFields = (stepIndex = 0): FormRenderFbFormModelField[][] => { + const stepFields = + steps[stepIndex] === undefined ? steps[steps.length - 1] : steps[stepIndex]; + const fieldLayout = cloneDeep(stepFields.layout.filter(Boolean)); const validatorPlugins = plugins.byType("fb-form-field-validator"); @@ -171,6 +207,14 @@ const FormRender: React.FC = props => { }); await handleFormTriggers({ props, data, formSubmission }); + + setTimeout(() => { + if (props.preview) { + setCurrentStepIndex(0); + resetLayoutRenderKey(); + } + }, 2000); + return formSubmission; }; @@ -205,6 +249,13 @@ const FormRender: React.FC = props => { getDefaultValues, getFields, submit, + goToNextStep, + goToPreviousStep, + isLastStep, + isFirstStep, + currentStepIndex, + currentStep, + isMultiStepForm, formData, ReCaptcha, reCaptchaEnabled: reCaptchaEnabled(formData), @@ -215,7 +266,7 @@ const FormRender: React.FC = props => { return ( <> - + ); }; diff --git a/packages/app-form-builder/src/components/Form/graphql.ts b/packages/app-form-builder/src/components/Form/graphql.ts index d6493e2ce65..568e3656d89 100644 --- a/packages/app-form-builder/src/components/Form/graphql.ts +++ b/packages/app-form-builder/src/components/Form/graphql.ts @@ -25,7 +25,10 @@ export const DATA_FIELDS = ` fields { ${FIELDS_FIELDS} } - layout + steps { + title + layout + } triggers settings { reCaptcha { diff --git a/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx b/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx index 3bc1cd9a697..41f50960f4a 100644 --- a/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx +++ b/packages/app-form-builder/src/page-builder/admin/plugins/components/PeFormElement.tsx @@ -32,9 +32,17 @@ const PeForm: FormRenderer = props => { return data.formBuilder.getPublishedForm.data; } catch { + /* + This query will always be called on the initial render of the page, + so if we have deleted the form that we were using on the page, + the page would crash. + Because we are trying to request a form, that does not exist anymore. + The "catch" helps to avoid that issue. + */ return apolloClient .query({ query: gql(GET_PUBLISHED_FORM), variables }) - .then(({ data }) => data.formBuilder.getPublishedForm.data); + .then(({ data }) => data.formBuilder.getPublishedForm.data) + .catch(() => null); } }, submitForm: ({ variables }) => { diff --git a/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx b/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx index 11c72a61212..81bf01ad74c 100644 --- a/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx +++ b/packages/app-form-builder/src/page-builder/render/plugins/PeFormElement.tsx @@ -32,9 +32,17 @@ const PeForm: FormRenderer = props => { return data.formBuilder.getPublishedForm.data; } catch { + /* + This query will always be called on the initial render of the page, + so if we have deleted the form that we were using on the page, + the page would crash. Because we are trying to request a form, + that does not exist anymore. + The "catch" helps to avoid that issue. + */ return apolloClient .query({ query: gql(GET_PUBLISHED_FORM), variables }) - .then(({ data }) => data.formBuilder.getPublishedForm.data); + .then(({ data }) => data.formBuilder.getPublishedForm.data) + .catch(() => null); } }, submitForm: ({ variables }) => { diff --git a/packages/app-form-builder/src/types.ts b/packages/app-form-builder/src/types.ts index 8dbbebfa300..38fe728d47f 100644 --- a/packages/app-form-builder/src/types.ts +++ b/packages/app-form-builder/src/types.ts @@ -84,10 +84,32 @@ export type FbFormFieldValidatorPlugin = Plugin & { export type FieldIdType = string; export type FbFormModelFieldsLayout = FieldIdType[][]; +export interface MoveFieldParams { + field: FieldIdType | FbFormModelField; + position: FieldLayoutPositionType; + targetStepId: string; + sourceStepId: string; +} + export interface FieldLayoutPositionType { row: number; index: number | null; } +export interface StepLayoutPositionType { + row: { + title: string; + id: string; + layout: string[][]; + }; + formStep: FbFormStep; + index: number | null; +} + +export interface FbFormStep { + id: string; + title: string; + layout: FbFormModelFieldsLayout; +} export type FbBuilderFieldPlugin = Plugin & { type: "form-editor-field-type"; @@ -148,8 +170,8 @@ export interface FbFormModel { id: FieldIdType; formId: string; version: number; - layout: FbFormModelFieldsLayout; fields: FbFormModelField[]; + steps: FbFormStep[]; published: boolean; name: string; settings: any; @@ -204,7 +226,7 @@ export interface FbFormSubmissionData { name: string; version: number; fields: FbFormModelField[]; - layout: string[][]; + steps: FbFormStep[]; }; } @@ -273,8 +295,15 @@ export type FormRenderFbFormModelField = FbFormModelField & { export type FormRenderPropsType> = { getFieldById: Function; getFieldByFieldId: Function; - getFields: () => FormRenderFbFormModelField[][]; + getFields: (stepIndex?: number) => FormRenderFbFormModelField[][]; getDefaultValues: () => { [key: string]: any }; + goToNextStep: () => void; + goToPreviousStep: () => void; + isLastStep: boolean; + isFirstStep: boolean; + isMultiStepForm: boolean; + currentStepIndex: number; + currentStep: FbFormStep; ReCaptcha: ReCaptchaComponent; reCaptchaEnabled: boolean; TermsOfService: TermsOfServiceComponent; @@ -394,7 +423,7 @@ export interface FbReCaptchaInput { } export interface FbFormSettingsInput { - layout: FbFormSettingsLayoutInput; + steps: FbFormStep[]; submitButtonLabel: string; fullWidthSubmitButton: boolean; successMessage: Record; @@ -405,7 +434,7 @@ export interface FbFormSettingsInput { export interface FbUpdateFormInput { name?: string; fields?: FbFormFieldInput[]; - layout?: FbFormModelFieldsLayout; + steps?: FbFormStep[]; settings?: FbFormSettingsInput; triggers?: Record; } diff --git a/packages/app-headless-cms-common/src/types/model.ts b/packages/app-headless-cms-common/src/types/model.ts index 22c052bb6a7..9803638f0e4 100644 --- a/packages/app-headless-cms-common/src/types/model.ts +++ b/packages/app-headless-cms-common/src/types/model.ts @@ -12,7 +12,7 @@ import { CmsIdentity } from "~/types/shared"; export type CmsEditorField = CmsModelField; export interface CmsModelFieldSettings { - defaultValue?: string | null | undefined; + defaultValue?: string | boolean | number | null | undefined; defaultSetValue?: string; type?: string; fields?: CmsModelField[]; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx index 3f4c7046a7e..3adf0da1cdb 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionDelete.tsx @@ -20,10 +20,10 @@ const ActionDelete = () => { return `${count} ${count === 1 ? "entry" : "entries"}`; }, [worker.items.length]); - const openPublishEntriesDialog = () => + const openDeleteEntriesDialog = () => showConfirmationDialog({ title: "Delete entries", - message: `You are about to publish ${entriesLabel}. Are you sure you want to continue?`, + message: `You are about to delete ${entriesLabel}. Are you sure you want to continue?`, loadingLabel: `Processing ${entriesLabel}`, execute: async () => { await worker.processInSeries(async ({ item, report }) => { @@ -69,7 +69,7 @@ const ActionDelete = () => { return ( } - onAction={openPublishEntriesDialog} + onAction={openDeleteEntriesDialog} label={`Delete ${entriesLabel}`} tooltipPlacement={"bottom"} /> diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx index cf89fbc3046..bee45c055fc 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionMove.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from "react"; -import { ReactComponent as MoveIcon } from "@material-design-icons/svg/round/drive_file_move.svg"; +import { ReactComponent as MoveIcon } from "@material-design-icons/svg/filled/drive_file_move.svg"; import { useRecords, useMoveToFolderDialog, useNavigateFolder } from "@webiny/app-aco"; import { FolderItem } from "@webiny/app-aco/types"; import { observer } from "mobx-react-lite"; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx index 099beb3b237..a55ebcd4ec7 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionPublish.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { ReactComponent as PublishIcon } from "@material-design-icons/svg/round/publish.svg"; +import { ReactComponent as PublishIcon } from "@material-design-icons/svg/filled/publish.svg"; import { useRecords } from "@webiny/app-aco"; import { observer } from "mobx-react-lite"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx index 44c4a022290..c103aea9349 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/BulkActions/ActionUnpublish.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { ReactComponent as UnpublishIcon } from "@material-design-icons/svg/round/settings_backup_restore.svg"; +import { ReactComponent as UnpublishIcon } from "@material-design-icons/svg/filled/settings_backup_restore.svg"; import { observer } from "mobx-react-lite"; import { useRecords } from "@webiny/app-aco"; import { ContentEntryListConfig } from "~/admin/config/contentEntries"; diff --git a/packages/app-headless-cms/src/admin/plugins/fields/boolean.tsx b/packages/app-headless-cms/src/admin/plugins/fields/boolean.tsx index d2ab279ce8c..a959afeff1e 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/boolean.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/boolean.tsx @@ -2,12 +2,50 @@ import React from "react"; import { ReactComponent as BooleanIcon } from "@material-design-icons/svg/outlined/toggle_on.svg"; import { CmsModelFieldTypePlugin } from "~/types"; import { i18n } from "@webiny/app/i18n"; -import { Grid, Cell } from "@webiny/ui/Grid"; -import { Select } from "@webiny/ui/Select"; +import { Cell, Grid } from "@webiny/ui/Grid"; import { Bind } from "@webiny/form"; +import { Radio, RadioGroup } from "@webiny/ui/Radio"; const t = i18n.ns("app-headless-cms/admin/fields"); +interface OptionsProps { + onChange: (value: boolean) => void; + value?: boolean | "true" | "false"; +} + +const Options: React.VFC = ({ onChange, value: initialValue }) => { + const value = initialValue === true || initialValue === "true"; + + return ( + + {() => { + return ( + <> +
+ { + onChange(true); + }} + /> +
+
+ { + onChange(false); + }} + /> +
+ + ); + }} +
+ ); +}; + const plugin: CmsModelFieldTypePlugin = { type: "cms-editor-field-type", name: "cms-editor-field-type-boolean", @@ -25,6 +63,9 @@ const plugin: CmsModelFieldTypePlugin = { validation: [], renderer: { name: "" + }, + settings: { + defaultValue: false } }; }, @@ -33,13 +74,9 @@ const plugin: CmsModelFieldTypePlugin = { - + {bind => { + return ; + }} diff --git a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx index c6391eaed66..8061e8c9a40 100644 --- a/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx +++ b/packages/app-page-builder-elements/src/renderers/form/FormRender.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { createReCaptchaComponent, createTermsOfServiceComponent } from "./FormRender/components"; import { createFormSubmission, @@ -36,6 +36,26 @@ export interface FormRenderProps { const FormRender: React.FC = props => { const { formData, createFormParams } = props; const { preview = false, formLayoutComponents = [] } = createFormParams; + const [currentStepIndex, setCurrentStepIndex] = useState(0); + + // Check if the form is a multi step. + const isMultiStepForm = formData.steps.length > 1; + + const goToNextStep = () => { + setCurrentStepIndex(prevStep => (prevStep += 1)); + }; + + const goToPreviousStep = () => { + setCurrentStepIndex(prevStep => (prevStep -= 1)); + }; + + const isFirstStep = isMultiStepForm && currentStepIndex === 0; + const isLastStep = isMultiStepForm && currentStepIndex === formData.steps.length - 1; + + const currentStep = + formData.steps[currentStepIndex] === undefined + ? formData.steps[formData.steps.length - 1] + : formData.steps[currentStepIndex]; const fieldValidators = useMemo(() => { let validators: CreateFormParamsValidator[] = []; @@ -75,7 +95,7 @@ const FormRender: React.FC = props => { return
Selected form component not found.
; } - const { layout, fields, settings } = formData; + const { fields, settings, steps } = formData; const getFieldById = (id: string): FormDataField | null => { return fields.find(field => field._id === id) || null; @@ -85,8 +105,8 @@ const FormRender: React.FC = props => { return fields.find(field => field.fieldId === id) || null; }; - const getFields = (): FormRenderComponentDataField[][] => { - const fieldLayout = structuredClone(layout) as FormDataFieldsLayout; + const getFields = (stepIndex = 0): FormRenderComponentDataField[][] => { + const fieldLayout = structuredClone(steps[stepIndex].layout) as FormDataFieldsLayout; return fieldLayout.map(row => { return row.map(id => { @@ -171,7 +191,6 @@ const FormRender: React.FC = props => { } }; } - const formSubmission = await createFormSubmission({ props, formSubmissionFieldValues, @@ -200,6 +219,13 @@ const FormRender: React.FC = props => { getDefaultValues, getFields, submit, + goToNextStep, + goToPreviousStep, + isFirstStep, + isLastStep, + isMultiStepForm, + currentStepIndex, + currentStep, formData, ReCaptcha, reCaptchaEnabled: reCaptchaEnabled(formData), @@ -211,6 +237,7 @@ const FormRender: React.FC = props => { <> + ); }; diff --git a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts index 21548a0193d..384e2580c9f 100644 --- a/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts +++ b/packages/app-page-builder-elements/src/renderers/form/dataLoaders/graphql.ts @@ -23,7 +23,10 @@ export const GET_PUBLISHED_FORM = /* GraphQL */ ` } settings } - layout + steps { + title + layout + } triggers settings { reCaptcha { diff --git a/packages/app-page-builder-elements/src/renderers/form/types.ts b/packages/app-page-builder-elements/src/renderers/form/types.ts index bd139dd47bc..db8a9524896 100644 --- a/packages/app-page-builder-elements/src/renderers/form/types.ts +++ b/packages/app-page-builder-elements/src/renderers/form/types.ts @@ -43,12 +43,18 @@ export interface FormDataRevision { createdBy: FormDataCreatedBy; } +export interface FormDataStep { + id: string; + title: string; + layout: string[][]; +} + export interface FormData { id: string; formId: string; version: number; - layout: FormDataFieldsLayout; fields: FormDataField[]; + steps: FormDataStep[]; published: boolean; name: string; settings: any; @@ -77,8 +83,15 @@ export interface ErrorResponse { export type FormLayoutComponentProps = { getFieldById: Function; getFieldByFieldId: Function; - getFields: () => FormRenderComponentDataField[][]; + getFields: (stepIndex?: number) => FormRenderComponentDataField[][]; getDefaultValues: () => { [key: string]: any }; + goToNextStep: () => void; + goToPreviousStep: () => void; + isLastStep: boolean; + isFirstStep: boolean; + isMultiStepForm: boolean; + currentStepIndex: number; + currentStep: FormDataStep; ReCaptcha: ReCaptchaComponent; reCaptchaEnabled: boolean; TermsOfService: TermsOfServiceComponent; diff --git a/packages/app-page-builder/package.json b/packages/app-page-builder/package.json index d782de2e651..54a749d6866 100644 --- a/packages/app-page-builder/package.json +++ b/packages/app-page-builder/package.json @@ -38,6 +38,7 @@ "@webiny/lexical-editor": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/react-composition": "0.0.0", + "@webiny/react-properties": "0.0.0", "@webiny/react-router": "0.0.0", "@webiny/telemetry": "0.0.0", "@webiny/ui": "0.0.0", @@ -58,6 +59,7 @@ "is-hotkey": "^0.1.3", "lodash": "^4.17.10", "medium-editor": "^5.23.3", + "mobx-react-lite": "^3.4.3", "nanoid": "^3.1.20", "platform": "^1.3.5", "prop-types": "^15.7.2", diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 227c7efe210..427c545178d 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -11,8 +11,10 @@ import { ReactComponent as PagesIcon } from "./admin/assets/table_chart-24px.svg import { WebsiteSettings } from "./modules/WebsiteSettings/WebsiteSettings"; import { AdminPageBuilderContextProvider } from "~/admin/contexts/AdminPageBuilder"; import { DefaultOnPagePublish } from "~/admin/plugins/pageDetails/pageRevisions/DefaultOnPagePublish"; +import { DefaultOnPageUnpublish } from "~/admin/plugins/pageDetails/pageRevisions/DefaultOnPageUnpublish"; import { DefaultOnPageDelete } from "~/admin/plugins/pageDetails/pageRevisions/DefaultOnPageDelete"; import { EditorProps, EditorRenderer } from "./admin/components/Editor"; +import { PagesModule } from "~/admin/views/Pages/PagesModule"; export type { EditorProps }; export { EditorRenderer }; @@ -110,12 +112,14 @@ const EditorRendererPlugin = createComponentPlugin(EditorRenderer, () => { export const PageBuilder: React.FC = () => { return ( + + diff --git a/packages/app-page-builder/src/admin/components/BulkActions/ActionDelete.tsx b/packages/app-page-builder/src/admin/components/BulkActions/ActionDelete.tsx new file mode 100644 index 00000000000..e3fd92aab2c --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/ActionDelete.tsx @@ -0,0 +1,95 @@ +import React, { useMemo } from "react"; +import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete.svg"; +import { useRecords } from "@webiny/app-aco"; +import { observer } from "mobx-react-lite"; +import { PageListConfig } from "~/admin/config/pages"; +import { useAdminPageBuilder } from "~/admin/hooks/useAdminPageBuilder"; +import { usePagesPermissions } from "~/hooks/permissions"; +import { getPagesLabel } from "~/admin/components/BulkActions/BulkActions"; + +export const ActionDelete = observer(() => { + const { canDelete } = usePagesPermissions(); + const { deletePage, client } = useAdminPageBuilder(); + const { removeRecordFromCache } = useRecords(); + + const { useWorker, useButtons, useDialog } = PageListConfig.Browser.BulkAction; + const { IconButton } = useButtons(); + const worker = useWorker(); + const { showConfirmationDialog, showResultsDialog } = useDialog(); + + const pagesLabel = useMemo(() => { + return getPagesLabel(worker.items.length); + }, [worker.items.length]); + + const canDeleteAll = useMemo(() => { + return worker.items.every(item => canDelete(item?.createdBy?.id)); + }, [worker.items]); + + const openDeletePagesDialog = () => + showConfirmationDialog({ + title: "Delete pages", + message: `You are about to delete ${pagesLabel}. Are you sure you want to continue?`, + loadingLabel: `Processing ${pagesLabel}`, + execute: async () => { + await worker.processInSeries(async ({ item, report }) => { + try { + const response = await deletePage( + { id: item.id }, + { + client: client, + mutationOptions: { + update(_, { data }) { + if (data.pageBuilder.deletePage.error) { + return; + } + } + } + } + ); + + const { error } = response; + + if (error) { + throw new Error( + error.message || "Unknown error while deleting the page" + ); + } + + removeRecordFromCache(item.pid); + + report.success({ + title: `${item.title}`, + message: "Page successfully deleted." + }); + } catch (e) { + report.error({ + title: `${item.title}`, + message: e.message + }); + } + }); + + worker.resetItems(); + + showResultsDialog({ + results: worker.results, + title: "Delete pages", + message: "Operation completed, here below you find the complete report:" + }); + } + }); + + if (!canDeleteAll) { + console.log("Does not have permission to delete one or more pages."); + return null; + } + + return ( + } + onAction={openDeletePagesDialog} + label={`Delete ${pagesLabel}`} + tooltipPlacement={"bottom"} + /> + ); +}); diff --git a/packages/app-page-builder/src/admin/components/BulkActions/ActionExport.tsx b/packages/app-page-builder/src/admin/components/BulkActions/ActionExport.tsx new file mode 100644 index 00000000000..41883e552ce --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/ActionExport.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from "react"; +import { ReactComponent as ExportIcon } from "@material-design-icons/svg/outlined/file_download.svg"; +import { observer } from "mobx-react-lite"; +import { PageListConfig } from "~/admin/config/pages"; +import useExportPageRevisionSelectorDialog from "~/editor/plugins/defaultBar/components/ExportPageButton/useExportPageRevisionSelectorDialog"; +import useExportPageDialog from "~/editor/plugins/defaultBar/components/ExportPageButton/useExportPageDialog"; +import { getPagesLabel } from "~/admin/components/BulkActions/BulkActions"; + +export const ActionExport = observer(() => { + const { showExportPageRevisionSelectorDialog } = useExportPageRevisionSelectorDialog(); + const { showExportPageInitializeDialog } = useExportPageDialog(); + + const { useWorker, useButtons } = PageListConfig.Browser.BulkAction; + const { IconButton } = useButtons(); + const worker = useWorker(); + + const selected = useMemo(() => { + return worker.items.map(item => item.pid); + }, [worker.items]); + + const pagesLabel = useMemo(() => { + return getPagesLabel(selected.length); + }, [selected.length]); + + const openExportPagesDialog = () => + showExportPageRevisionSelectorDialog({ + onAccept: () => showExportPageInitializeDialog({ ids: selected }), + selected + }); + + return ( + } + onAction={openExportPagesDialog} + label={`Export ${pagesLabel}`} + tooltipPlacement={"bottom"} + /> + ); +}); diff --git a/packages/app-page-builder/src/admin/components/BulkActions/ActionMove.tsx b/packages/app-page-builder/src/admin/components/BulkActions/ActionMove.tsx new file mode 100644 index 00000000000..b08e7b71748 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/ActionMove.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useMemo } from "react"; +import { ReactComponent as MoveIcon } from "@material-design-icons/svg/outlined/drive_file_move.svg"; +import { useRecords, useMoveToFolderDialog, useNavigateFolder } from "@webiny/app-aco"; +import { FolderItem } from "@webiny/app-aco/types"; +import { observer } from "mobx-react-lite"; +import { PageListConfig } from "~/admin/config/pages"; +import { ROOT_FOLDER } from "~/admin/constants"; +import { getPagesLabel } from "~/admin/components/BulkActions/BulkActions"; + +export const ActionMove = observer(() => { + const { moveRecord } = useRecords(); + const { currentFolderId } = useNavigateFolder(); + + const { useWorker, useButtons, useDialog } = PageListConfig.Browser.BulkAction; + const { IconButton } = useButtons(); + const worker = useWorker(); + const { showConfirmationDialog, showResultsDialog } = useDialog(); + const { showDialog: showMoveDialog } = useMoveToFolderDialog(); + + const pagesLabel = useMemo(() => { + return getPagesLabel(worker.items.length); + }, [worker.items.length]); + + const openWorkerDialog = useCallback( + (folder: FolderItem) => { + showConfirmationDialog({ + title: "Move pages", + message: `You are about to move ${pagesLabel} to ${folder.title}. Are you sure you want to continue?`, + loadingLabel: `Processing ${pagesLabel}`, + execute: async () => { + await worker.processInSeries(async ({ item, report }) => { + try { + await moveRecord({ + id: item.pid, + location: { + folderId: folder.id + } + }); + + report.success({ + title: `${item.title}`, + message: "Page successfully moved." + }); + } catch (e) { + report.error({ + title: `${item.title}`, + message: e.message + }); + } + }); + + worker.resetItems(); + + showResultsDialog({ + results: worker.results, + title: "Move pages", + message: "Operation completed, here below you find the complete report:" + }); + } + }); + }, + [pagesLabel] + ); + + const openMovePagesDialog = () => + showMoveDialog({ + title: "Select folder", + message: "Select a new location for selected pages:", + loadingLabel: `Processing ${pagesLabel}`, + acceptLabel: `Move`, + focusedFolderId: currentFolderId || ROOT_FOLDER, + async onAccept({ folder }) { + openWorkerDialog(folder); + } + }); + + return ( + } + onAction={openMovePagesDialog} + label={`Move ${pagesLabel}`} + tooltipPlacement={"bottom"} + /> + ); +}); diff --git a/packages/app-page-builder/src/admin/components/BulkActions/ActionPublish.tsx b/packages/app-page-builder/src/admin/components/BulkActions/ActionPublish.tsx new file mode 100644 index 00000000000..6d588611e0b --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/ActionPublish.tsx @@ -0,0 +1,83 @@ +import React, { useMemo } from "react"; +import { ReactComponent as PublishIcon } from "@material-design-icons/svg/outlined/publish.svg"; +import { useRecords } from "@webiny/app-aco"; +import { observer } from "mobx-react-lite"; +import { PageListConfig } from "~/admin/config/pages"; +import { useAdminPageBuilder } from "~/admin/hooks/useAdminPageBuilder"; +import { usePagesPermissions } from "~/hooks/permissions"; +import { getPagesLabel } from "~/admin/components/BulkActions/BulkActions"; + +export const ActionPublish = observer(() => { + const { canPublish } = usePagesPermissions(); + const { publishPage, client } = useAdminPageBuilder(); + const { getRecord } = useRecords(); + + const { useWorker, useButtons, useDialog } = PageListConfig.Browser.BulkAction; + const { IconButton } = useButtons(); + const worker = useWorker(); + const { showConfirmationDialog, showResultsDialog } = useDialog(); + + const pagesLabel = useMemo(() => { + return getPagesLabel(worker.items.length); + }, [worker.items.length]); + + const openPublishPagesDialog = () => + showConfirmationDialog({ + title: "Publish pages", + message: `You are about to publish ${pagesLabel}. Are you sure you want to continue?`, + loadingLabel: `Processing ${pagesLabel}`, + execute: async () => { + await worker.processInSeries(async ({ item, report }) => { + try { + const response = await publishPage( + { id: item.id }, + { + client: client + } + ); + + const { error, page } = response; + + if (error) { + throw new Error( + error.message || "Unknown error while publishing the pages" + ); + } + + await getRecord(page.id); + + report.success({ + title: `${item.title}`, + message: "Page successfully published." + }); + } catch (e) { + report.error({ + title: `${item.title}`, + message: e.message + }); + } + }); + + worker.resetItems(); + + showResultsDialog({ + results: worker.results, + title: "Publish pages", + message: "Operation completed, here below you find the complete report:" + }); + } + }); + + if (!canPublish()) { + return null; + } + + return ( + } + onAction={openPublishPagesDialog} + label={`Publish ${pagesLabel}`} + tooltipPlacement={"bottom"} + /> + ); +}); diff --git a/packages/app-page-builder/src/admin/components/BulkActions/ActionUnpublish.tsx b/packages/app-page-builder/src/admin/components/BulkActions/ActionUnpublish.tsx new file mode 100644 index 00000000000..6d58ea8a07c --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/ActionUnpublish.tsx @@ -0,0 +1,83 @@ +import React, { useMemo } from "react"; +import { ReactComponent as UnpublishIcon } from "@material-design-icons/svg/outlined/settings_backup_restore.svg"; +import { observer } from "mobx-react-lite"; +import { useRecords } from "@webiny/app-aco"; +import { PageListConfig } from "~/admin/config/pages"; +import { useAdminPageBuilder } from "~/admin/hooks/useAdminPageBuilder"; +import { usePagesPermissions } from "~/hooks/permissions"; +import { getPagesLabel } from "~/admin/components/BulkActions/BulkActions"; + +export const ActionUnpublish = observer(() => { + const { canUnpublish } = usePagesPermissions(); + const { unpublishPage, client } = useAdminPageBuilder(); + const { getRecord } = useRecords(); + + const { useWorker, useButtons, useDialog } = PageListConfig.Browser.BulkAction; + const { IconButton } = useButtons(); + const worker = useWorker(); + const { showConfirmationDialog, showResultsDialog } = useDialog(); + + const pagesLabel = useMemo(() => { + return getPagesLabel(worker.items.length); + }, [worker.items.length]); + + const openUnpublishPagesDialog = () => + showConfirmationDialog({ + title: "Unpublish pages", + message: `You are about to unpublish ${pagesLabel}. Are you sure you want to continue?`, + loadingLabel: `Processing ${pagesLabel}`, + execute: async () => { + await worker.processInSeries(async ({ item, report }) => { + try { + const response = await unpublishPage( + { id: item.id }, + { + client: client + } + ); + + const { error, page } = response; + + if (error) { + throw new Error( + error.message || "Unknown error while unpublishing the page" + ); + } + + await getRecord(page.id); + + report.success({ + title: `${item.title}`, + message: "Page successfully unpublished." + }); + } catch (e) { + report.error({ + title: `${item.title}`, + message: e.message + }); + } + }); + + worker.resetItems(); + + showResultsDialog({ + results: worker.results, + title: "Unpublish pages", + message: "Operation completed, here below you find the complete report:" + }); + } + }); + + if (!canUnpublish()) { + return null; + } + + return ( + } + onAction={openUnpublishPagesDialog} + label={`Unpublish ${pagesLabel}`} + tooltipPlacement={"bottom"} + /> + ); +}); diff --git a/packages/app-page-builder/src/admin/components/BulkActions/BulkActions.tsx b/packages/app-page-builder/src/admin/components/BulkActions/BulkActions.tsx new file mode 100644 index 00000000000..2715593fcce --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/BulkActions.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from "react"; +import { ReactComponent as Close } from "@material-design-icons/svg/outlined/close.svg"; +import { Buttons } from "@webiny/app-admin"; +import { IconButton } from "@webiny/ui/Button"; + +import { usePageListConfig } from "~/admin/config/pages"; +import { usePagesList } from "~/admin/views/Pages/hooks/usePagesList"; + +import { BulkActionsContainer, BulkActionsInner, ButtonsContainer } from "./styles"; +import { Typography } from "@webiny/ui/Typography"; +import { i18n } from "@webiny/app/i18n"; + +const t = i18n.ns("app-page-builder/admin/components/bulk-actions"); + +export const getPagesLabel = (count = 0): string => { + return `${count} ${count === 1 ? "page" : "pages"}`; +}; + +export const BulkActions = () => { + const { browser } = usePageListConfig(); + const { selected, setSelected } = usePagesList(); + + const headline = useMemo((): string => { + return t`{label} selected:`({ + label: getPagesLabel(selected.length) + }); + }, [selected]); + + if (!selected.length) { + return null; + } + + return ( + + + + {headline} + + + } onClick={() => setSelected([])} /> + + + ); +}; diff --git a/packages/app-page-builder/src/admin/components/BulkActions/index.tsx b/packages/app-page-builder/src/admin/components/BulkActions/index.tsx new file mode 100644 index 00000000000..4c8c6c07d86 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/index.tsx @@ -0,0 +1,6 @@ +export { ActionDelete } from "./ActionDelete"; +export { ActionExport } from "./ActionExport"; +export { ActionMove } from "./ActionMove"; +export { ActionPublish } from "./ActionPublish"; +export { ActionUnpublish } from "./ActionUnpublish"; +export * from "./BulkActions"; diff --git a/packages/app-page-builder/src/admin/components/BulkActions/styles.tsx b/packages/app-page-builder/src/admin/components/BulkActions/styles.tsx new file mode 100644 index 00000000000..c8ed13ba467 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/BulkActions/styles.tsx @@ -0,0 +1,30 @@ +import styled from "@emotion/styled"; +import { ButtonContainer } from "@webiny/app-admin"; + +export const BulkActionsContainer = styled.div` + width: 100%; + height: 64px; + background-color: var(--mdc-theme-surface); + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + position: absolute; + top: 0; + left: 0; + z-index: 4; +`; + +export const BulkActionsInner = styled.div` + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; +`; + +export const ButtonsContainer = styled.div` + display: flex; + align-items: center; + + ${ButtonContainer} { + margin: 0; + } +`; diff --git a/packages/app-page-builder/src/admin/components/OptionsMenu.tsx b/packages/app-page-builder/src/admin/components/OptionsMenu.tsx index 11bb2d6ac69..55d7236268e 100644 --- a/packages/app-page-builder/src/admin/components/OptionsMenu.tsx +++ b/packages/app-page-builder/src/admin/components/OptionsMenu.tsx @@ -37,9 +37,10 @@ export const OptionsMenu = makeComposable( return ( } />} + handle={ + } data-testid={props["data-testid"]} /> + } anchor={"topLeft"} - {...props} > {items.map(item => ( void; onImportPage: (event?: React.SyntheticEvent) => void; onCreateFolder: (event?: React.SyntheticEvent) => void; - selected: string[]; + selected: PbPageDataItem[]; searchValue: string; onSearchChange: (value: string) => void; } diff --git a/packages/app-page-builder/src/admin/components/Table/Header/TableActions/TableActions.tsx b/packages/app-page-builder/src/admin/components/Table/Header/TableActions/TableActions.tsx index d9fc3b979b1..f28b2d9c35d 100644 --- a/packages/app-page-builder/src/admin/components/Table/Header/TableActions/TableActions.tsx +++ b/packages/app-page-builder/src/admin/components/Table/Header/TableActions/TableActions.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement } from "react"; +import React, { ReactElement, useCallback, useMemo } from "react"; import { ReactComponent as ExportIcon } from "@material-design-icons/svg/outlined/file_download.svg"; import { ReactComponent as ImportIcon } from "@material-design-icons/svg/outlined/file_upload.svg"; @@ -9,10 +9,12 @@ import { IconButton } from "@webiny/ui/Button"; import useExportPageRevisionSelectorDialog from "~/editor/plugins/defaultBar/components/ExportPageButton/useExportPageRevisionSelectorDialog"; import useExportPageDialog from "~/editor/plugins/defaultBar/components/ExportPageButton/useExportPageDialog"; +import { PbPageDataItem } from "~/types"; + const t = i18n.ns("app-page-builder/admin/views/pages/table/header/buttons/table-actions"); export interface TableActionsProps { - selected: string[]; + selected: PbPageDataItem[]; onImportPage: (event?: React.SyntheticEvent) => void; } @@ -20,7 +22,7 @@ export const TableActions = ({ selected, onImportPage }: TableActionsProps): Rea const { showExportPageRevisionSelectorDialog } = useExportPageRevisionSelectorDialog(); const { showExportPageInitializeDialog } = useExportPageDialog(); - const renderExportPagesTooltip = (selected: string[]) => { + const renderExportPagesTooltip = useCallback(() => { const count = selected.length; if (count > 0) { return t`Export {count|count:1:page:default:pages}`({ @@ -29,23 +31,27 @@ export const TableActions = ({ selected, onImportPage }: TableActionsProps): Rea } return t`Export all pages`; - }; + }, [selected.length]); + + const selectedIds = useMemo(() => { + return selected.map(item => item.pid); + }, [selected]); return ( <> } onClick={onImportPage} /> - + } onClick={() => { showExportPageRevisionSelectorDialog({ onAccept: () => showExportPageInitializeDialog({ - ids: selected + ids: selectedIds }), - selected + selected: selectedIds }); }} /> diff --git a/packages/app-page-builder/src/admin/components/Table/Table/index.tsx b/packages/app-page-builder/src/admin/components/Table/Table/index.tsx index 8517c383925..6b662f2d501 100644 --- a/packages/app-page-builder/src/admin/components/Table/Table/index.tsx +++ b/packages/app-page-builder/src/admin/components/Table/Table/index.tsx @@ -28,7 +28,7 @@ export interface TableProps { loading?: boolean; openPreviewDrawer: () => void; onSelectRow: (rows: Entry[] | []) => void; - selectedRows: string[]; + selectedRows: PbPageDataItem[]; sorting: Sorting; onSortingChange: OnSortingChange; } @@ -90,9 +90,9 @@ const createFoldersData = (items: FolderItem[]): FolderEntry[] => { }); }; -function isPageEntry(entry: Entry): entry is PageEntry { +export const isPageEntry = (entry: Entry): entry is PageEntry => { return entry.$type === "RECORD"; -} +}; export const Table = forwardRef((props, ref) => { const { @@ -205,7 +205,9 @@ export const Table = forwardRef((props, ref) => { stickyRows={1} onSelectRow={onSelectRow} sorting={sorting} - selectedRows={data.filter(record => selectedRows.includes(record.id))} + selectedRows={data.filter(record => + selectedRows.find(row => row.pid === record.id) + )} isRowSelectable={row => row.original.$selectable} onSortingChange={onSortingChange} initialSorting={[ diff --git a/packages/app-page-builder/src/admin/config/pages/index.ts b/packages/app-page-builder/src/admin/config/pages/index.ts new file mode 100644 index 00000000000..491ccf0c124 --- /dev/null +++ b/packages/app-page-builder/src/admin/config/pages/index.ts @@ -0,0 +1 @@ +export * from "./list"; diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/BulkAction.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/BulkAction.tsx new file mode 100644 index 00000000000..cdcdf72085f --- /dev/null +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/BulkAction.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useEffect, useRef } from "react"; +import { CallbackParams, useButtons, useDialogWithReport, Worker } from "@webiny/app-admin"; +import { Property, useIdGenerator } from "@webiny/react-properties"; +import { usePagesList } from "~/admin/views/Pages/hooks/usePagesList"; +import { PbPageDataItem } from "~/types"; + +export interface BulkActionConfig { + name: string; + element: React.ReactElement; +} + +export interface BulkActionProps { + name: string; + remove?: boolean; + before?: string; + after?: string; + element: React.ReactElement; +} + +export const BaseBulkAction: React.FC = ({ + name, + after = undefined, + before = undefined, + remove = false, + element +}) => { + const getId = useIdGenerator("bulkAction"); + + const placeAfter = after !== undefined ? getId(after) : undefined; + const placeBefore = before !== undefined ? getId(before) : undefined; + + return ( + + + + + + + ); +}; + +const useWorker = () => { + const { selected, setSelected } = usePagesList(); + const { current: worker } = useRef(new Worker()); + + useEffect(() => { + worker.items = selected; + }, [selected]); + + // Reset selected items in both usePagesList and Worker + const resetItems = useCallback(() => { + worker.items = []; + setSelected([]); + }, []); + + return { + items: worker.items, + process: (callback: (items: PbPageDataItem[]) => void) => worker.process(callback), + processInSeries: async ( + callback: ({ item, allItems, report }: CallbackParams) => Promise, + chunkSize?: number + ) => worker.processInSeries(callback, chunkSize), + resetItems: resetItems, + results: worker.results + }; +}; + +export const BulkAction = Object.assign(BaseBulkAction, { + useButtons, + useWorker, + useDialog: useDialogWithReport +}); diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts b/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts new file mode 100644 index 00000000000..7f46a7f0ccb --- /dev/null +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/index.ts @@ -0,0 +1,9 @@ +import { BulkAction, BulkActionConfig } from "./BulkAction"; + +export interface BrowserConfig { + bulkActions: BulkActionConfig[]; +} + +export const Browser = { + BulkAction +}; diff --git a/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx b/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx new file mode 100644 index 00000000000..5a7485790f2 --- /dev/null +++ b/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { createConfigurableComponent } from "@webiny/react-properties"; +import { Browser, BrowserConfig } from "./Browser"; + +const base = createConfigurableComponent("PageListConfig"); + +export const PageListConfig = Object.assign(base.Config, { Browser }); +export const PageListWithConfig = base.WithConfig; + +interface PageListConfig { + browser: BrowserConfig; +} + +export function usePageListConfig() { + const config = base.useConfig(); + + const browser = config.browser || {}; + + return useMemo( + () => ({ + browser: { + ...browser, + bulkActions: [...(browser.bulkActions || [])] + } + }), + [config] + ); +} diff --git a/packages/app-page-builder/src/admin/config/pages/list/index.ts b/packages/app-page-builder/src/admin/config/pages/list/index.ts new file mode 100644 index 00000000000..7f66bd8faeb --- /dev/null +++ b/packages/app-page-builder/src/admin/config/pages/list/index.ts @@ -0,0 +1 @@ +export * from "./PageListConfig"; diff --git a/packages/app-page-builder/src/admin/contexts/AdminPageBuilder.tsx b/packages/app-page-builder/src/admin/contexts/AdminPageBuilder.tsx index 5353729c2b8..354737e852a 100644 --- a/packages/app-page-builder/src/admin/contexts/AdminPageBuilder.tsx +++ b/packages/app-page-builder/src/admin/contexts/AdminPageBuilder.tsx @@ -13,22 +13,27 @@ interface Page { id: string; } -export interface PublishPageOptions { +interface MutationPageOptions { mutationOptions?: MutationHookOptions; client: ApolloClient; } -export type DeletePageOptions = PublishPageOptions; +export type PublishPageOptions = MutationPageOptions; +export type UnpublishPageOptions = MutationPageOptions; +export type DeletePageOptions = MutationPageOptions; export interface AdminPageBuilderContext extends PageBuilderContext { publishPage: (page: Page, options: PublishPageOptions) => Promise; onPagePublish: (fn: OnPagePublishSubscriber) => () => void; + unpublishPage: (page: Page, options: UnpublishPageOptions) => Promise; + onPageUnpublish: (fn: OnPageUnpublishSubscriber) => () => void; deletePage: (page: Page, options: DeletePageOptions) => Promise; onPageDelete: (fn: OnPageDeleteSubscriber) => () => void; client: ApolloClient; } type OnPagePublishSubscriber = AsyncProcessor; +type OnPageUnpublishSubscriber = AsyncProcessor; type OnPageDeleteSubscriber = AsyncProcessor; interface PageError { @@ -44,12 +49,14 @@ interface OnPagePublish { error?: PageError; } +type OnPageUnpublish = OnPagePublish; type OnPageDelete = OnPagePublish; export const AdminPageBuilderContextProvider: React.FC = ({ children }) => { const pageBuilder = usePageBuilder(); const client = useApolloClient(); const onPagePublish = useRef([]); + const onPageUnpublish = useRef([]); const onPageDelete = useRef([]); const context: AdminPageBuilderContext = useMemo(() => { @@ -68,6 +75,19 @@ export const AdminPageBuilderContextProvider: React.FC = ({ children }) => { onPagePublish.current.splice(index, 1); }; }, + async unpublishPage(page, options) { + return await composeAsync([...onPageUnpublish.current].reverse())({ + page, + options + }); + }, + onPageUnpublish: fn => { + onPageUnpublish.current.push(fn); + return () => { + const index = onPageUnpublish.current.length; + onPageUnpublish.current.splice(index, 1); + }; + }, async deletePage(page, options) { return await composeAsync([...onPageDelete.current].reverse())({ page, diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/DefaultOnPageUnpublish.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/DefaultOnPageUnpublish.tsx new file mode 100644 index 00000000000..c464f54eccf --- /dev/null +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/DefaultOnPageUnpublish.tsx @@ -0,0 +1,90 @@ +import { useEffect } from "react"; +import get from "lodash/get"; +import { set, merge } from "dot-prop-immutable"; +import cloneDeep from "lodash/cloneDeep"; +import { MutationUpdaterFn } from "apollo-client"; +import { useAdminPageBuilder } from "~/admin/hooks/useAdminPageBuilder"; +import { GET_PAGE, UNPUBLISH_PAGE } from "~/admin/graphql/pages"; +import { UnpublishPageOptions } from "~/admin/contexts/AdminPageBuilder"; +import { PageStatus, PbPageData } from "~/types"; + +const getUpdateCache = + (page: Pick): MutationUpdaterFn => + (cache, { data }) => { + // Don't do anything if there was an error during unpublishing! + if (data.pageBuilder.unpublishPage.error) { + return; + } + + // Update revisions + let pageFromCache; + try { + pageFromCache = cloneDeep( + cache.readQuery({ + query: GET_PAGE, + variables: { id: page.id } + }) + ); + } catch { + // This means page could not be found in the cache. Exiting... + return; + } + + const revisions = get(pageFromCache, "pageBuilder.getPage.data.revisions", []); + revisions.forEach((r: any) => { + // Update published/locked fields on the revision that was just published. + if (r.id === page.id) { + r.status = PageStatus.UNPUBLISHED; + r.locked = true; + return; + } + }); + + // Write our data back to the cache. + cache.writeQuery({ + query: GET_PAGE, + data: set(pageFromCache, "pageBuilder.getPage.data.revisions", revisions) + }); + }; + +export const DefaultOnPageUnpublish = () => { + const { onPageUnpublish } = useAdminPageBuilder(); + + const handleUnpublishPage = async ( + revision: Pick, + options: UnpublishPageOptions + ) => { + const response = await options.client.mutate({ + mutation: UNPUBLISH_PAGE, + variables: { id: revision.id }, + update: getUpdateCache(revision), + ...(get(options, "mutationOptions", {}) as any) + }); + + const { error, data } = get(response, "data.pageBuilder.unpublishPage"); + + return { + error, + data + }; + }; + + useEffect(() => { + return onPageUnpublish(next => async params => { + const result = await next(params); + + /** + * If there is error in one of previous hooks. Don't execute the action and just return the result. + */ + if (result.error) { + return result; + } + + const { error, data } = await handleUnpublishPage(result.page, result.options); + + return { ...merge(result, "page", data), error }; + }); + }, []); + + return null; +}; diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/usePublishRevisionHandler.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/usePublishRevisionHandler.tsx index 33ecfac0e60..30432014fe0 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/usePublishRevisionHandler.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/pageRevisions/usePublishRevisionHandler.tsx @@ -1,13 +1,10 @@ import React from "react"; -import { useApolloClient } from "@apollo/react-hooks"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; -import { UNPUBLISH_PAGE } from "~/admin/graphql/pages"; import { useAdminPageBuilder } from "~/admin/hooks/useAdminPageBuilder"; import { useRecords } from "@webiny/app-aco"; import { PbPageDataItem } from "~/types"; export function usePublishRevisionHandler() { - const client = useApolloClient(); const { showSnackbar } = useSnackbar(); const pageBuilder = useAdminPageBuilder(); const { getRecord } = useRecords(); @@ -36,30 +33,24 @@ export function usePublishRevisionHandler() { const unpublishRevision = async ( revision: Pick ): Promise => { - const { data: res } = await client.mutate({ - mutation: UNPUBLISH_PAGE, - variables: { id: revision.id }, - update: (cache, { data }) => { - // Don't do anything if there was an error during publishing! - if (data.pageBuilder.unpublishPage.error) { - return; - } - } + const response = await pageBuilder.unpublishPage(revision, { + client: pageBuilder.client }); + if (response) { + const { error } = response; + if (error) { + return showSnackbar(error.message); + } - const { error } = res.pageBuilder.unpublishPage; - if (error) { - return showSnackbar(error.message); - } - - // Sync ACO record - retrieve the most updated record from network - await getRecord(revision.pid); + // Sync ACO record - retrieve the most updated record from network + await getRecord(revision.pid); - showSnackbar( - - Successfully unpublished revision #{revision.version}! - - ); + showSnackbar( + + Successfully unpublished revision #{revision.version}! + + ); + } }; return { diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx index dbd82f06b44..37482606d31 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesDataList.tsx @@ -203,7 +203,12 @@ const PageBuilderBlockCategoriesDataList = ({ {canDelete(item?.createdBy?.id) && ( - deleteItem(item)} /> + deleteItem(item)} + data-testid={ + "pb-block-categories-list-delete-block-category-btn" + } + /> )} diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx index 90f9387f8a6..b65639f85ab 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx @@ -80,7 +80,10 @@ const EmptyTemplateDetails: React.FC = ({ onCreate, c })} action={ canCreate ? ( - + } /> {t`New Template`} ) : ( diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx index d5f2c8404c7..5ebcddaf9ec 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx @@ -50,10 +50,12 @@ const DataListActionsWrapper = styled.div` justify-content: flex-end; align-items: center; `; + interface Sorter { label: string; sort: string; } + const SORTERS: Sorter[] = [ { label: t`Newest to oldest`, @@ -157,16 +159,20 @@ const PageTemplatesDataList = ({ } return ( - + } /> {t`New Template`} , onClick: showImportDialog, - "data-testid": "import-template-button" + "data-testid": "pb-templates-list-options-import-template-btn" } ]} /> @@ -260,6 +266,9 @@ const PageTemplatesDataList = ({ {canEdit(template) && ( } onClick={() => history.push( @@ -270,6 +279,9 @@ const PageTemplatesDataList = ({ )} {canDelete(template) && ( } onClick={() => onDelete(template)} /> diff --git a/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx b/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx new file mode 100644 index 00000000000..da61600f698 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/Pages/PagesModule.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { PageListConfig } from "~/admin/config/pages"; + +import { + ActionDelete, + ActionExport, + ActionMove, + ActionPublish, + ActionUnpublish +} from "~/admin/components/BulkActions"; + +const { Browser } = PageListConfig; + +export const PagesModule: React.FC = () => { + return ( + <> + + } /> + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/packages/app-page-builder/src/admin/views/Pages/Table/Main.tsx b/packages/app-page-builder/src/admin/views/Pages/Table/Main.tsx index 9ee969f5204..171cb5ecbed 100644 --- a/packages/app-page-builder/src/admin/views/Pages/Table/Main.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/Table/Main.tsx @@ -10,6 +10,7 @@ import PageTemplatesDialog from "~/admin/views/Pages/PageTemplatesDialog"; import useCreatePage from "~/admin/views/Pages/hooks/useCreatePage"; import useImportPage from "~/admin/views/Pages/hooks/useImportPage"; import { usePagesList } from "~/admin/views/Pages/hooks/usePagesList"; +import { BulkActions } from "~/admin/components/BulkActions"; import { Empty } from "~/admin/components/Table/Empty"; import { Header } from "~/admin/components/Table/Header"; import { LoadingMore } from "~/admin/components/Table/LoadingMore"; @@ -109,6 +110,7 @@ export const Main: React.VFC = ({ folderId: initialFolderId }) => { searchValue={list.search} onSearchChange={list.setSearch} /> + {list.records.length === 0 && list.folders.length === 0 && diff --git a/packages/app-page-builder/src/admin/views/Pages/Table/index.tsx b/packages/app-page-builder/src/admin/views/Pages/Table/index.tsx index d769253d9c5..199a607cf25 100644 --- a/packages/app-page-builder/src/admin/views/Pages/Table/index.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/Table/index.tsx @@ -10,6 +10,8 @@ import { import { AcoProvider, useNavigateFolder } from "@webiny/app-aco"; import { useApolloClient } from "@apollo/react-hooks"; import { usePagesPermissions } from "~/hooks/permissions"; +import { PagesListProvider } from "~/admin/views/Pages/hooks/usePagesList"; +import { PageListWithConfig } from "~/admin/config/pages"; const View: React.VFC = () => { const { currentFolderId } = useNavigateFolder(); @@ -45,7 +47,11 @@ const Index: React.VFC = () => { createNavigateFolderStorageKey={createNavigateFolderStorageKey} own={canAccessOnlyOwn()} > - + + + + + ); }; diff --git a/packages/app-page-builder/src/admin/views/Pages/hooks/usePagesList.ts b/packages/app-page-builder/src/admin/views/Pages/hooks/usePagesList.tsx similarity index 72% rename from packages/app-page-builder/src/admin/views/Pages/hooks/usePagesList.ts rename to packages/app-page-builder/src/admin/views/Pages/hooks/usePagesList.tsx index 4a304566e78..71a62222e61 100644 --- a/packages/app-page-builder/src/admin/views/Pages/hooks/usePagesList.ts +++ b/packages/app-page-builder/src/admin/views/Pages/hooks/usePagesList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useRouter } from "@webiny/react-router"; import debounce from "lodash/debounce"; import { PAGE_BUILDER_LIST_LINK } from "~/admin/constants"; @@ -6,9 +6,18 @@ import { createSort, useAcoList } from "@webiny/app-aco"; import { PbPageDataItem } from "~/types"; import { FolderItem, ListMeta, SearchRecordItem } from "@webiny/app-aco/types"; import { OnSortingChange, Sorting } from "@webiny/ui/DataTable"; -import { TableProps } from "~/admin/components/Table/Table"; +import { PageEntry, TableProps, isPageEntry } from "~/admin/components/Table/Table"; -interface UsePageList { +interface UpdateSearchCallableParams { + search: string; + query: URLSearchParams; +} + +interface UpdateSearchCallable { + (params: UpdateSearchCallableParams): void; +} + +interface PagesListProviderContext { folders: FolderItem[]; isListLoading: boolean; isListLoadingMore: boolean; @@ -19,22 +28,22 @@ interface UsePageList { onSelectRow: TableProps["onSelectRow"]; records: SearchRecordItem[]; search: string; - selected: string[]; + selected: PbPageDataItem[]; setSearch: (value: string) => void; + setSelected: (data: PbPageDataItem[]) => void; setSorting: OnSortingChange; sorting: Sorting; } -interface UpdateSearchCallableParams { - search: string; - query: URLSearchParams; -} +export const PagesListContext = React.createContext( + undefined +); -interface UpdateSearchCallable { - (params: UpdateSearchCallableParams): void; +interface PagesListProviderProps { + children: React.ReactNode; } -export const usePagesList = (): UsePageList => { +export const PagesListProvider = ({ children }: PagesListProviderProps) => { const { history } = useRouter(); const { @@ -46,14 +55,14 @@ export const usePagesList = (): UsePageList => { listTitle, meta, records, + selected, setSearchQuery, + setSelected, setListSort } = useAcoList(); const [sorting, setSorting] = useState([]); const [search, setSearch] = useState(""); - const [selected, setSelected] = useState([]); - const query = new URLSearchParams(location.search); const searchQuery = query.get("search") || ""; @@ -92,10 +101,11 @@ export const usePagesList = (): UsePageList => { updateSearch({ search, query }); }, [search]); - // Handle rows selection, receiving the full row object and setting the `row.id`, internally mapped to `page.pid`. + // Handle rows selection. const onSelectRow: TableProps["onSelectRow"] = rows => { - const ids = rows.filter(row => row.$type === "RECORD").map(row => row.id); - setSelected(ids); + const recordEntries = rows.filter(isPageEntry) as PageEntry[]; + const pageEntries = recordEntries.map(record => record.original); + setSelected(pageEntries); }; useEffect(() => { @@ -109,7 +119,7 @@ export const usePagesList = (): UsePageList => { setListSort(sort); }, [sorting]); - return { + const context: PagesListProviderContext = { folders, isListLoading, isListLoadingMore, @@ -122,7 +132,20 @@ export const usePagesList = (): UsePageList => { search, selected, setSearch, + setSelected, setSorting, sorting }; + + return {children}; +}; + +export const usePagesList = (): PagesListProviderContext => { + const context = React.useContext(PagesListContext); + + if (!context) { + throw new Error("usePagesList must be used within a PagesListContext"); + } + + return context; }; diff --git a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportBlockButton/useExportBlockDialog.tsx b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportBlockButton/useExportBlockDialog.tsx index 9e76f38c6c6..241fae77e6b 100644 --- a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportBlockButton/useExportBlockDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportBlockButton/useExportBlockDialog.tsx @@ -77,7 +77,11 @@ const ExportBlockDialogMessage: React.FC = ({ exportUrl
- + {exportUrl} @@ -116,6 +120,7 @@ interface UseExportBlockDialog { showExportBlockInitializeDialog: (props: ExportBlockLoadingDialogProps) => void; hideDialog: () => void; } + const useExportBlockDialog = (): UseExportBlockDialog => { const { showDialog, hideDialog } = useDialog(); diff --git a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/useExportPageDialog.tsx b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/useExportPageDialog.tsx index 90d9094c3d0..d71e8dbfe23 100644 --- a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/useExportPageDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportPageButton/useExportPageDialog.tsx @@ -89,7 +89,11 @@ const ExportPageDialogMessage: React.FC = ({ exportUrl })
- + {exportUrl} @@ -128,6 +132,7 @@ interface UseExportPageDialog { showExportPageInitializeDialog: (props: ExportPagesDialogProps) => void; hideDialog: () => void; } + const useExportPageDialog = (): UseExportPageDialog => { const { showDialog, hideDialog } = useDialog(); diff --git a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportTemplateButton/useExportTemplateDialog.tsx b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportTemplateButton/useExportTemplateDialog.tsx index c3c82243fa3..993a4e559c7 100644 --- a/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportTemplateButton/useExportTemplateDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/defaultBar/components/ExportTemplateButton/useExportTemplateDialog.tsx @@ -89,7 +89,11 @@ const ExportTemplateDialogMessage: React.FC = ({ expo
- + {exportUrl} @@ -128,6 +132,7 @@ interface UseExportTemplateDialog { showExportTemplateInitializeDialog: (props: ExportTemplatesDialogProps) => void; hideDialog: () => void; } + const useExportTemplateDialog = (): UseExportTemplateDialog => { const { showDialog, hideDialog } = useDialog(); diff --git a/packages/app-page-builder/src/editor/plugins/elements/carousel/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/carousel/index.tsx index d280189c908..302c2199218 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/carousel/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/carousel/index.tsx @@ -55,7 +55,7 @@ export default (args: PbEditorElementPluginArgs = {}): PbEditorPageElementPlugin settings: typeof args.settings === "function" ? args.settings(defaultSettings) : defaultSettings, canDelete: () => { - return false; + return true; }, create: () => { const defaultValue = { diff --git a/packages/app-page-builder/tsconfig.build.json b/packages/app-page-builder/tsconfig.build.json index 8e7a8c5a653..7adb4a69ecd 100644 --- a/packages/app-page-builder/tsconfig.build.json +++ b/packages/app-page-builder/tsconfig.build.json @@ -15,6 +15,7 @@ { "path": "../lexical-editor/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../react-composition/tsconfig.build.json" }, + { "path": "../react-properties/tsconfig.build.json" }, { "path": "../react-router/tsconfig.build.json" }, { "path": "../ui/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, diff --git a/packages/app-page-builder/tsconfig.json b/packages/app-page-builder/tsconfig.json index 060b37feb68..9fba3fdffe4 100644 --- a/packages/app-page-builder/tsconfig.json +++ b/packages/app-page-builder/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../lexical-editor" }, { "path": "../plugins" }, { "path": "../react-composition" }, + { "path": "../react-properties" }, { "path": "../react-router" }, { "path": "../ui" }, { "path": "../utils" }, @@ -53,6 +54,8 @@ "@webiny/plugins": ["../plugins/src"], "@webiny/react-composition/*": ["../react-composition/src/*"], "@webiny/react-composition": ["../react-composition/src"], + "@webiny/react-properties/*": ["../react-properties/src/*"], + "@webiny/react-properties": ["../react-properties/src"], "@webiny/react-router/*": ["../react-router/src/*"], "@webiny/react-router": ["../react-router/src"], "@webiny/ui/*": ["../ui/src/*"], diff --git a/packages/cli-plugin-deploy-pulumi/commands/executeMigrations.js b/packages/cli-plugin-deploy-pulumi/commands/executeMigrations.js index dd103c3c758..6c162d30854 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/executeMigrations.js +++ b/packages/cli-plugin-deploy-pulumi/commands/executeMigrations.js @@ -1,14 +1,12 @@ -const readline = require("readline"); const LambdaClient = require("aws-sdk/clients/lambda"); const { getStackOutput } = require("../utils"); -const { runMigration, printReport, getDuration } = require("@webiny/data-migration/cli"); - -const clearLine = () => { - if (process.stdout.isTTY) { - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - } -}; +const { + MigrationRunner, + InteractiveCliStatusReporter, + NonInteractiveCliStatusReporter, + LogReporter, + CliMigrationRunReporter +} = require("@webiny/data-migration/cli"); module.exports = async (params, context) => { const apiOutput = getStackOutput({ folder: "apps/api", env: params.env }); @@ -20,35 +18,29 @@ module.exports = async (params, context) => { region: apiOutput.region }); - const response = await runMigration({ + const functionName = apiOutput["migrationLambdaArn"]; + + const logReporter = new LogReporter(functionName); + const statusReporter = + !process.stdout.isTTY || "CI" in process.env + ? new NonInteractiveCliStatusReporter(logReporter) + : new InteractiveCliStatusReporter(logReporter); + + const runner = MigrationRunner.create({ lambdaClient, - functionName: apiOutput["migrationLambdaArn"], - payload: { - version: process.env.WEBINY_VERSION || context.version, - pattern: params.pattern - }, - statusCallback: ({ status, migrations }) => { - clearLine(); - if (status === "running") { - const currentMigration = migrations.find(mig => mig.status === "running"); - if (currentMigration) { - const duration = getDuration(currentMigration.startedOn); - process.stdout.write( - `Running data migration ${currentMigration.id} (${duration})...` - ); - } - return; - } - - if (status === "init") { - process.stdout.write(`Checking data migrations...`); - } - } + functionName, + statusReporter }); - clearLine(); + const result = await runner.runMigration({ + version: process.env.WEBINY_VERSION || context.version, + pattern: params.pattern + }); - printReport({ response, context, migrationLambdaArn: apiOutput["migrationLambdaArn"] }); + if (result) { + const reporter = new CliMigrationRunReporter(logReporter, context); + await reporter.report(result); + } } catch (e) { context.error(`An error occurred while trying to execute data migration Lambda function!`); console.log(e); diff --git a/packages/cli-plugin-deploy-pulumi/commands/index.js b/packages/cli-plugin-deploy-pulumi/commands/index.js index 920c41ea43e..c7aba6996d9 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/index.js +++ b/packages/cli-plugin-deploy-pulumi/commands/index.js @@ -315,24 +315,6 @@ module.exports = [ process.exit(0); } ); - - yargs.command( - "get-migration-status", - `Get data migrations Lambda status.`, - () => { - yargs.example("$0 get-migration-status --env dev"); - - yargs.option("env", { - describe: `Environment`, - type: "string", - required: true - }); - }, - async argv => { - await require("./printMigrationStatus")(argv, context); - process.exit(0); - } - ); } } ]; diff --git a/packages/cli-plugin-deploy-pulumi/commands/printMigrationStatus.js b/packages/cli-plugin-deploy-pulumi/commands/printMigrationStatus.js deleted file mode 100644 index 0589e4a4b71..00000000000 --- a/packages/cli-plugin-deploy-pulumi/commands/printMigrationStatus.js +++ /dev/null @@ -1,31 +0,0 @@ -const LambdaClient = require("aws-sdk/clients/lambda"); -const { getStackOutput } = require("../utils"); -const { runMigration, printReport } = require("@webiny/data-migration/cli"); - -/** - * On every deployment of the API project application, this plugin invokes the data migrations Lambda. - */ -module.exports = async (params, context) => { - const apiOutput = getStackOutput({ folder: "apps/api", env: params.env }); - - context.info("Fetching migration status..."); - - try { - const lambdaClient = new LambdaClient({ - region: apiOutput.region - }); - - const response = await runMigration({ - lambdaClient, - functionName: apiOutput["migrationLambdaArn"], - payload: { - version: process.env.WEBINY_VERSION || context.version - } - }); - - printReport({ response, context, migrationLambdaArn: apiOutput["migrationLambdaArn"] }); - } catch (e) { - context.error(`An error occurred while trying to execute data migration Lambda function!`); - console.log(e); - } -}; diff --git a/packages/create-webiny-project/README.md b/packages/create-webiny-project/README.md index 66806892226..e943a74f605 100644 --- a/packages/create-webiny-project/README.md +++ b/packages/create-webiny-project/README.md @@ -12,27 +12,33 @@ A tool for setting up a new Webiny project. #### Simple: ``` -npx create-webiny-project@beta my-test-project --tag beta +npx create-webiny-project@local-npm my-test-project --tag local-npm ``` #### Advanced: ``` -npx create-webiny-project@beta my-test-project - --tag beta --no-interactive +npx create-webiny-project@local-npm my-test-project + --tag local-npm --no-interactive --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}' --template-options '{"region":"eu-central-1","vpc":false}' ``` This usage is more ideal for CI/CD environments, where interactivity is not available. -But do note that this is probably more useful to us, Webiny developers, than for actual Webiny projects. This is simply because in real project's CI/CD pipelines, users would simply start off by cloning the project from their private repository, and not create a new one with the above command. +But do note that this is probably more useful to us, Webiny developers, than for actual Webiny projects. This is simply +because in real project's CI/CD pipelines, users would simply start off by cloning the project from their private +repository, and not create a new one with the above command. ## Development Notes -Testing this, and related packages (like [cwp-template-aws](./../cwp-template-aws)) is a bit complicated, because in order to get the best results, it's recommended to test everything with packages published to a real NPM. +Testing this, and related packages (like [cwp-template-aws](./../cwp-template-aws)) is a bit complicated, because in +order to get the best results, it's recommended to test everything with packages published to a real NPM. -But of course, publishing to NPM just to test something is not ideal, and that's why, we use [Verdaccio](https://verdaccio.org/) instead, which is, basically, an NPM-like service you can run locally. So, instead of publishing packages to NPM, you publish them to Verdaccio, which is much cleaner, because everything stays on your laptop. +But of course, publishing to NPM just to test something is not ideal, and that's why, we +use [Verdaccio](https://verdaccio.org/) instead, which is, basically, an NPM-like service you can run locally. So, +instead of publishing packages to NPM, you publish them to Verdaccio, which is much cleaner, because everything stays on +your laptop. #### Usage @@ -42,19 +48,25 @@ The following steps show how to do it. #### 1. Start Verdaccio -Start by running the `yarn verdaccio:start` command, which will, as the script name itself suggests, spin up Verdaccio locally. +Start by running the `yarn verdaccio:start` command, which will, as the script name itself suggests, spin up Verdaccio +locally. -> All of the files uploaded to Verdaccio service will be stored in the `.verdaccio` folder, located in your project root. +> All of the files uploaded to Verdaccio service will be stored in the `.verdaccio` folder, located in your project +> root. #### 2. Set default NPM registry -Once you have Verdaccio up and running, you'll also need to change the default NPM registry. Meaning, when you run `npx create-webiny-project ...`, you want it to start fetching packages from Verdaccio, not real NPM. Verdaccio runs on localhost, on port 4873, so, in your terminal, run the following command: +Once you have Verdaccio up and running, you'll also need to change the default NPM registry. Meaning, when you +run `npx create-webiny-project ...`, you want it to start fetching packages from Verdaccio, not real NPM. Verdaccio runs +on localhost, on port 4873, so, in your terminal, run the following command: ``` npm config set registry http://localhost:4873 ``` -Note that this will only help you with `npx`, but won't help you when a new project foundation is created, and the dependencies start to get pulled. This is because we're using yarn2, which actually doesn't respect the values that were written by the `npm config set ...` command we just executed. +Note that this will only help you with `npx`, but won't help you when a new project foundation is created, and the +dependencies start to get pulled. This is because we're using yarn2, which actually doesn't respect the values that were +written by the `npm config set ...` command we just executed. It's super important that, when you're testing your npx project, you also pass the following argument: @@ -62,15 +74,16 @@ It's super important that, when you're testing your npx project, you also pass t --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}' ``` -This will set the necessary values in yarn2 config file, which will be located in your newly created project. But don't worry about it right now, this will be revisited in step 4. +This will set the necessary values in yarn2 config file, which will be located in your newly created project. But don't +worry about it right now, this will be revisited in step 4. -> Yarn2 projects don't rely on global configurations and is not installed globally, but on per-project basis. This allows having multiple versions of yarn2, for different projects. +> Yarn2 projects don't rely on global configurations and is not installed globally, but on per-project basis. This +> allows having multiple versions of yarn2, for different projects. #### 3. Release Commit (no need to push it if you don't want to) all of the code changes, and execute the following command: - ```bash yarn release --type=verdaccio ``` @@ -80,7 +93,7 @@ yarn release --type=verdaccio Test your changes with the following command: ``` -npx create-webiny-project@next my-test-project --tag next --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}' +npx create-webiny-project@local-npm my-test-project --tag local-npm --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}' ``` This should create a project, with all of the packages pulled from Verdaccio. @@ -94,17 +107,17 @@ Once you're done, do the following: ### Commands Cheat Sheet -| Description | Command | -|-----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Remove `.verdaccio` folder | `rm -rf .verdaccio` | -| List all v5\* tags | `git tag -l "v5*"` | -| Remove specific tag | `git tag -d "v5.0.0-next.5"` | -| Set Verdaccio as the NPM registry | `npm config set registry http://localhost:4873` | -| Reset NPM registry | `npm config set registry https://registry.npmjs.org/` | -| Start Verdaccio | `yarn verdaccio:start` | -| Release to Verdaccio | `yarn release --type=verdaccio` | -| Create a new Webiny project | `npx create-webiny-project@next my-test-project --tag next --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}'` | -| Revert versioning commit | `git reset HEAD~ && git reset --hard HEAD` | +| Description | Command | +|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Remove `.verdaccio` folder | `rm -rf .verdaccio` | +| List all v5\* tags | `git tag -l "v5*"` | +| Remove specific tag | `git tag -d "v5.0.0-next.5"` | +| Set Verdaccio as the NPM registry | `npm config set registry http://localhost:4873` | +| Reset NPM registry | `npm config set registry https://registry.npmjs.org/` | +| Start Verdaccio | `yarn verdaccio:start` | +| Release to Verdaccio | `yarn release --type=verdaccio` | | +| Create a new Webiny project | `npx create-webiny-project@local-npm my-test-project --tag local-npm --assign-to-yarnrc '{"npmRegistryServer":"http://localhost:4873","unsafeHttpWhitelist":["localhost"]}` | +| Revert versioning commit | `git reset HEAD~ && git reset --hard HEAD` | ## Troubleshooting @@ -112,18 +125,23 @@ Once you're done, do the following: This is probably because of one of the Yarn package caching mechanisms. -Yarn has two levels of cache - local and shared. +Yarn has two levels of cache - local and shared. -When you install a package, it gets cached in the local cache folder (located in your project), and in the shared cache folder. This makes it much faster when you're working on a couple of projects on your local machine, and you're pulling the same package in each. If the package doesn't exist in local cache, it will be pulled from shared cache. +When you install a package, it gets cached in the local cache folder (located in your project), and in the shared cache +folder. This makes it much faster when you're working on a couple of projects on your local machine, and you're pulling +the same package in each. If the package doesn't exist in local cache, it will be pulled from shared cache. -On Windows, the shared cache folder should be located in: `C:\Users\{USER-NAME}\AppData\Local\Yarn`. +On Windows, the shared cache folder should be located in: `C:\Users\{USER-NAME}\AppData\Local\Yarn`. On Linux/Mac, the shared cache folder should be located in: `/Users/adrian/Library/Caches/Yarn`. -In these folders, most probably, you'll also have the `\Berry\cache` folder. But, there were also cases where this folder did not exist. +In these folders, most probably, you'll also have the `\Berry\cache` folder. But, there were also cases where this +folder did not exist. -Deleting the mentioned cache folders should help with the issue of still receiving old packages in your testing sessions. +Deleting the mentioned cache folders should help with the issue of still receiving old packages in your testing +sessions. -With all of this being said, you can also try the [following command](https://yarnpkg.com/features/offline-cache#cleaning-the-cache): +With all of this being said, you can also try +the [following command](https://yarnpkg.com/features/offline-cache#cleaning-the-cache): ```bash yarn cache clean --mirror diff --git a/packages/cwp-template-aws/cli/aws/checkEsServiceRole.js b/packages/cwp-template-aws/cli/aws/checkEsServiceRole.js deleted file mode 100644 index 88709770ca4..00000000000 --- a/packages/cwp-template-aws/cli/aws/checkEsServiceRole.js +++ /dev/null @@ -1,39 +0,0 @@ -const IAM = require("aws-sdk/clients/iam"); -const ora = require("ora"); -const { green } = require("chalk"); - -module.exports = { - type: "hook-before-deploy", - name: "hook-before-deploy-es-service-role", - async hook({ projectApplication }, context) { - if (projectApplication.id !== "api") { - return; - } - - const spinner = new ora(); - spinner.start(`Checking Elastic Search service role...`); - const iam = new IAM(); - try { - await iam - .getRole({ RoleName: "AWSServiceRoleForAmazonElasticsearchService" }) - .promise(); - - spinner.stop({ - symbol: green("✔"), - text: `Found Elastic Search service role!` - }); - context.success(`Found Elastic Search service role!`); - } catch (err) { - spinner.text = "Creating Elastic Search service role..."; - - try { - await iam.createServiceLinkedRole({ AWSServiceName: "es.amazonaws.com" }).promise(); - - spinner.stop(); - } catch (err) { - spinner.fail(err.message); - process.exit(1); - } - } - } -}; diff --git a/packages/cwp-template-aws/cli/aws/index.js b/packages/cwp-template-aws/cli/aws/index.js index a6ad51aa2e6..3a5b9c136c8 100644 --- a/packages/cwp-template-aws/cli/aws/index.js +++ b/packages/cwp-template-aws/cli/aws/index.js @@ -1,5 +1 @@ -module.exports = [ - require("./checkCredentials"), - require("./checkEsServiceRole"), - require("./subscriptionRequiredException") -]; +module.exports = [require("./checkCredentials"), require("./subscriptionRequiredException")]; diff --git a/packages/cwp-template-aws/package.json b/packages/cwp-template-aws/package.json index 415a1426d7d..9849cc58f2d 100644 --- a/packages/cwp-template-aws/package.json +++ b/packages/cwp-template-aws/package.json @@ -21,7 +21,6 @@ "inquirer": "7.3.3", "load-json-file": "6.2.0", "lodash": "^4.17.20", - "ora": "4.1.1", "write-json-file": "4.3.0" }, "devDependencies": { diff --git a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts index c0e20887254..b10411e4512 100644 --- a/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts +++ b/packages/cwp-template-aws/template/ddb-es/apps/api/graphql/src/types.ts @@ -10,6 +10,7 @@ import { FileManagerContext } from "@webiny/api-file-manager/types"; import { FormBuilderContext } from "@webiny/api-form-builder/types"; import { CmsContext } from "@webiny/api-headless-cms/types"; import { AcoContext } from "@webiny/api-aco/types"; +import { PbAcoContext } from "@webiny/api-page-builder-aco/types"; // When working with the `context` object (for example while defining a new GraphQL resolver function), // you can import this interface and assign it to it. This will give you full autocomplete functionality @@ -29,4 +30,5 @@ export interface Context FileManagerContext, FormBuilderContext, AcoContext, + PbAcoContext, CmsContext {} diff --git a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts index 1b220ea4464..0afc073fa70 100644 --- a/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts +++ b/packages/cwp-template-aws/template/ddb/apps/api/graphql/src/types.ts @@ -9,6 +9,7 @@ import { FileManagerContext } from "@webiny/api-file-manager/types"; import { FormBuilderContext } from "@webiny/api-form-builder/types"; import { CmsContext } from "@webiny/api-headless-cms/types"; import { AcoContext } from "@webiny/api-aco/types"; +import { PbAcoContext } from "@webiny/api-page-builder-aco/types"; // When working with the `context` object (for example while defining a new GraphQL resolver function), // you can import this interface and assign it to it. This will give you full autocomplete functionality @@ -27,4 +28,5 @@ export interface Context FileManagerContext, FormBuilderContext, AcoContext, + PbAcoContext, CmsContext {} diff --git a/packages/data-migration/src/MigrationRunner.ts b/packages/data-migration/src/MigrationRunner.ts index 1173a2832be..21516f33bc6 100644 --- a/packages/data-migration/src/MigrationRunner.ts +++ b/packages/data-migration/src/MigrationRunner.ts @@ -33,6 +33,12 @@ const getRunItemDuration = (runItem: MigrationRunItem) => { return new Date(runItem.finishedOn).getTime() - new Date(runItem.startedOn).getTime(); }; +const shouldForceExecute = (mig: DataMigration) => { + const key = `WEBINY_MIGRATION_FORCE_EXECUTE_${mig.getId().replace(/[\.\-]/g, "_")}`; + + return process.env[key] === "true"; +}; + class MigrationNotFinished extends Error {} class MigrationInProgress extends Error {} @@ -41,6 +47,7 @@ export class MigrationRunner { private readonly migrations: DataMigration[]; private readonly repository: MigrationRepository; private readonly timeLimiter: ExecutionTimeLimiter; + private context: Record = {}; constructor( repository: MigrationRepository, @@ -58,6 +65,10 @@ export class MigrationRunner { this.logger = logger; } + setContext(context: Record) { + this.context = context; + } + async execute(projectVersion: string, isApplicable?: IsMigrationApplicable) { const lastRun = await this.getOrCreateRun(); @@ -113,6 +124,10 @@ export class MigrationRunner { const executableMigrations = this.migrations .filter(mig => { + if (shouldForceExecute(mig)) { + return true; + } + if (!isMigrationApplicable(mig)) { this.setRunItem(lastRun, { id: mig.getId(), @@ -136,6 +151,7 @@ export class MigrationRunner { return this.timeLimiter() < 120000; }; + // for (const migration of executableMigrations) { const runItem = this.getOrCreateRunItem(lastRun, migration); const checkpoint = await this.repository.getCheckpoint(migration.getId()); @@ -149,6 +165,7 @@ export class MigrationRunner { projectVersion, logger, checkpoint, + forceExecute: shouldForceExecute(migration), runningOutOfTime: shouldCreateCheckpoint, createCheckpoint: async (data: unknown) => { await this.createCheckpoint(migration, data); @@ -160,7 +177,10 @@ export class MigrationRunner { } }; try { - const shouldExecute = checkpoint ? true : await migration.shouldExecute(context); + const shouldExecute = + checkpoint || shouldForceExecute(migration) + ? true + : await migration.shouldExecute(context); if (!shouldExecute) { this.logger.info(`Skipping migration %s.`, migration.getId()); @@ -301,7 +321,8 @@ export class MigrationRunner { status: "init", startedOn: getCurrentISOTime(), finishedOn: "", - migrations: [] + migrations: [], + context: this.context }; await this.repository.saveRun(lastRun); diff --git a/packages/data-migration/src/cli/CliMigrationRunReporter.ts b/packages/data-migration/src/cli/CliMigrationRunReporter.ts new file mode 100644 index 00000000000..81fde81bcdb --- /dev/null +++ b/packages/data-migration/src/cli/CliMigrationRunReporter.ts @@ -0,0 +1,61 @@ +import { MigrationRunnerResult, MigrationRunReporter } from "~/cli"; +import center from "center-align"; +import { CliContext } from "@webiny/cli/types"; +import { LogReporter } from "~/cli"; + +export class CliMigrationRunReporter implements MigrationRunReporter { + private context: CliContext; + private logReporter: LogReporter; + + constructor(logReporter: LogReporter, context: CliContext) { + this.logReporter = logReporter; + this.context = context; + } + + report(result: MigrationRunnerResult): Promise { + result.onSuccess(data => { + const functionName = result.getFunctionName().split(":").pop(); + process.stdout.write("\n"); + this.context.success(`Data migration Lambda %s executed successfully!`, functionName); + + const { migrations, ...run } = data; + if (!migrations.length) { + this.context.info(`No applicable migrations were found!`); + return; + } + + const maxLength = Math.max(...migrations.map(mig => mig.status.length)) + 2; + this.context.info(`Migration run: %s`, run.id); + this.context.info(`Status: %s`, run.status); + this.context.info(`Started on: %s`, run.startedOn); + if (run.status === "done") { + this.context.info(`Finished on: %s`, run.finishedOn); + } + for (const migration of migrations) { + this.context.info( + ...[ + `[%s] %s: ${migration.description}`, + center(this.makeEven(migration.status), maxLength), + migration.id + ] + ); + } + + this.logReporter.printLogStreamLinks(); + }); + + result.onError(error => { + this.context.error(error.message); + }); + + // Process the result! + return result.process(); + } + + private makeEven(str: string) { + if (str.length % 2 > 0) { + return str + " "; + } + return str; + } +} diff --git a/packages/data-migration/src/cli/InteractiveCliStatusReporter.ts b/packages/data-migration/src/cli/InteractiveCliStatusReporter.ts new file mode 100644 index 00000000000..09caca52ac4 --- /dev/null +++ b/packages/data-migration/src/cli/InteractiveCliStatusReporter.ts @@ -0,0 +1,78 @@ +import readline from "readline"; +import { MigrationStatusReporter } from "~/cli/MigrationStatusReporter"; +import { MigrationStatus } from "~/types"; +import { LogReporter } from "~/cli/LogReporter"; + +export class InteractiveCliStatusReporter implements MigrationStatusReporter { + private logReporter: LogReporter; + private firstCall = true; + + constructor(logReporter: LogReporter) { + this.logReporter = logReporter; + console.log(`Using "InteractiveCliStatusReporter".`); + } + + async report(migrationStatus: MigrationStatus) { + const { status, migrations, context } = migrationStatus; + this.clearLine(); + + const currentLogStreamName = context?.logStreamName; + if (currentLogStreamName) { + this.logReporter.initializeStream(currentLogStreamName); + if (this.firstCall) { + this.logReporter.printLogStreamLinks(); + process.stdout.write(`\n---------- MIGRATION LOGS START ----------\n\n`); + } + await this.logReporter.printLogs(currentLogStreamName); + } + + if (status === "running") { + const currentMigration = migrations.find(mig => mig.status === "running"); + if (currentMigration) { + const duration = this.getDuration(String(currentMigration.startedOn)); + process.stdout.write( + `Running data migration ${currentMigration.id} (${duration})...` + ); + } + } + + if (status === "init") { + process.stdout.write(`Checking data migrations...`); + } + + if (["done", "error"].includes(status)) { + this.clearLine(); + process.stdout.write(`Migration run finished, waiting for latest logs...`); + + // We want to give AWS some time for the latest log events to become available. + await new Promise(resolve => { + setTimeout(resolve, 8000); + }); + + if (currentLogStreamName) { + this.clearLine(); + await this.logReporter.printLogs(currentLogStreamName); + process.stdout.write(`\n---------- MIGRATION LOGS END ----------\n`); + } + } + + this.firstCall = false; + } + + private clearLine() { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } + + private getDuration(since: string) { + const ms = new Date().getTime() - new Date(since).getTime(); + let seconds = Math.floor(ms / 1000); + let minutes = undefined; + if (seconds > 60) { + minutes = Math.floor(seconds / 60); + seconds = Math.floor(seconds % 60); + } + + return minutes ? `${minutes}m ${seconds}s` : `${seconds}s`; + } +} diff --git a/packages/data-migration/src/cli/LogReporter.ts b/packages/data-migration/src/cli/LogReporter.ts new file mode 100644 index 00000000000..e5ade856d47 --- /dev/null +++ b/packages/data-migration/src/cli/LogReporter.ts @@ -0,0 +1,49 @@ +import { LogStream } from "./LogStream"; + +export class LogReporter { + private readonly logGroupName: string; + private readonly logsCreatedSince: number; + private readonly logStreams = new Set(); + + constructor(functionName: string) { + const baseName = functionName.split(":").pop(); + this.logGroupName = `/aws/lambda/${baseName}`; + this.logsCreatedSince = Date.now(); + } + + public async printLogs(logStreamName: string) { + const logStream = this.initializeStream(logStreamName); + await logStream.printLogsSince(this.logsCreatedSince); + } + + public printLogStreamLinks() { + if (this.logStreams.size === 0) { + return; + } + + const logStreams = Array.from(this.logStreams); + + if (this.logStreams.size === 1) { + process.stdout.write( + `\nTo view detailed logs, visit the following AWS CloudWatch log stream:\n` + ); + process.stdout.write(logStreams[0].getLogStreamLink()); + } else { + process.stdout.write( + `\nTo view detailed logs, visit the following AWS CloudWatch log streams:\n` + ); + + for (const logStream of logStreams) { + process.stdout.write(`- ${logStream.getLogStreamLink()}`); + } + } + + process.stdout.write("\n"); + } + + public initializeStream(name: string) { + const logStream = LogStream.create(this.logGroupName, name); + this.logStreams.add(logStream); + return logStream; + } +} diff --git a/packages/data-migration/src/cli/LogStream.ts b/packages/data-migration/src/cli/LogStream.ts new file mode 100644 index 00000000000..98f40ac87bc --- /dev/null +++ b/packages/data-migration/src/cli/LogStream.ts @@ -0,0 +1,76 @@ +import CloudWatchLogs from "aws-sdk/clients/cloudwatchlogs"; + +const cache = new Map(); + +export class LogStream { + private readonly logGroupName: string; + private readonly logStreamName: string; + private readonly cloudWatchLogs: CloudWatchLogs; + private nextPage: string | undefined; + + private constructor(logGroupName: string, logStreamName: string) { + this.logGroupName = logGroupName; + this.logStreamName = logStreamName; + this.cloudWatchLogs = new CloudWatchLogs(); + } + + getLogStreamLink() { + const replacements = [ + [/\$/g, "$2524"], + [/\//g, "$252F"], + [/\[/g, "$255B"], + [/]/g, "$255D"] + ]; + + const replacer = (value: string, replacement: (string | RegExp)[]) => { + return value.replace(replacement[0], replacement[1] as string); + }; + + return [ + `https://${process.env.AWS_REGION}.console.aws.amazon.com/cloudwatch/home?region=${process.env.AWS_REGION}#logsV2:log-groups/log-group/`, + replacements.reduce(replacer, this.logGroupName), + "/log-events/", + replacements.reduce(replacer, this.logStreamName) + ].join(""); + } + + async printLogsSince(startTime: number): Promise { + const params: CloudWatchLogs.Types.GetLogEventsRequest = { + logStreamName: this.logStreamName, + logGroupName: this.logGroupName, + nextToken: this.nextPage, + startFromHead: true, + startTime, + unmask: true + }; + + try { + const { events, nextForwardToken } = await this.cloudWatchLogs + .getLogEvents(params) + .promise(); + + this.nextPage = nextForwardToken; + + if (events) { + events.forEach(event => { + process.stdout.write(String(event.message)); + }); + } + } catch (err) { + console.log(`Couldn't fetch logs: ${err.message}`); + } + } + + public static create(logGroupName: string, logStreamName: string) { + const cacheId = `${logGroupName}:${logStreamName}`; + + if (cache.has(cacheId)) { + return cache.get(cacheId) as LogStream; + } + + const logStream = new LogStream(logGroupName, logStreamName); + cache.set(cacheId, logStream); + + return logStream; + } +} diff --git a/packages/data-migration/src/cli/MigrationRunReporter.ts b/packages/data-migration/src/cli/MigrationRunReporter.ts new file mode 100644 index 00000000000..5e4eb87f8a2 --- /dev/null +++ b/packages/data-migration/src/cli/MigrationRunReporter.ts @@ -0,0 +1,5 @@ +import { MigrationRunnerResult } from "~/cli/MigrationRunner"; + +export interface MigrationRunReporter { + report(result: MigrationRunnerResult): void | Promise; +} diff --git a/packages/data-migration/src/cli/MigrationRunner.ts b/packages/data-migration/src/cli/MigrationRunner.ts new file mode 100644 index 00000000000..a317a1fd0aa --- /dev/null +++ b/packages/data-migration/src/cli/MigrationRunner.ts @@ -0,0 +1,176 @@ +import LambdaClient from "aws-sdk/clients/lambda"; +import { MigrationStatusReporter } from "~/cli/MigrationStatusReporter"; +import { + MigrationEventHandlerResponse, + MigrationInvocationErrorResponse, + MigrationStatus, + MigrationStatusResponse +} from "~/types"; +import { executeWithRetry } from "@webiny/utils"; +import { VoidStatusReporter } from "./VoidStatusReporter"; + +interface MigrationRunnerConfig { + lambdaClient: LambdaClient; + functionName: string; + statusReporter?: MigrationStatusReporter; +} + +interface MigrationPayload { + version: string; + pattern?: string; +} + +interface SuccessResultHandler { + (result: MigrationStatusResponse["data"]): void | Promise; +} + +interface ErrorResultHandler { + (error: MigrationInvocationErrorResponse["error"]): void | Promise; +} + +export class MigrationRunnerResult { + private readonly functionName: string; + private readonly result: MigrationStatusResponse | MigrationInvocationErrorResponse; + private readonly successBranch: SuccessResultHandler[] = []; + private readonly errorBranch: ErrorResultHandler[] = []; + + constructor( + functionName: string, + result: MigrationStatusResponse | MigrationInvocationErrorResponse + ) { + this.functionName = functionName; + this.result = result; + } + + getFunctionName() { + return this.functionName; + } + + onSuccess(cb: SuccessResultHandler) { + this.successBranch.push(cb); + } + + onError(cb: ErrorResultHandler) { + this.errorBranch.push(cb); + } + + async process(): Promise { + const branch = this.result.error ? this.errorBranch : this.successBranch; + const input = this.result.error ? this.result.error : this.result.data; + + for (const handler of branch) { + await handler(input as any); + } + } +} + +export class MigrationRunner { + private readonly lambdaClient: LambdaClient; + private readonly functionName: string; + private statusReporter: MigrationStatusReporter = new VoidStatusReporter(); + + public static create(params: MigrationRunnerConfig) { + const runner = new MigrationRunner(params.lambdaClient, params.functionName); + if (params.statusReporter) { + runner.setStatusReporter(params.statusReporter); + } + return runner; + } + + private constructor(lambdaClient: LambdaClient, functionName: string) { + this.lambdaClient = lambdaClient; + this.functionName = functionName; + } + + public setStatusReporter(reporter: MigrationStatusReporter) { + this.statusReporter = reporter; + } + + async runMigration(payload: MigrationPayload): Promise { + // Execute migration function. + await this.invokeMigration(payload); + + // Poll for status and re-execute when migration is in "pending" state. + let response: MigrationEventHandlerResponse; + + while (true) { + await new Promise(resolve => + setTimeout(resolve, this.getMigrationStatusReportInterval()) + ); + + response = await this.getStatus(payload); + + if (!response) { + continue; + } + + const { data, error } = response; + + // If we received an error, it must be an unrecoverable error, and we don't retry. + if (error) { + return this.getResult(response); + } + + switch (data.status) { + case "init": + await this.reportStatus(data); + continue; + case "pending": + await this.invokeMigration(payload); + break; + case "running": + await this.reportStatus(data); + break; + case "done": + await this.reportStatus(data); + return this.getResult(response); + default: + return this.getResult(response); + } + } + } + + private async reportStatus(data: MigrationStatus) { + await this.statusReporter.report(data); + } + + private async invokeMigration(payload: MigrationPayload) { + const response = await this.lambdaClient + .invoke({ + FunctionName: this.functionName, + InvocationType: "Event", + Payload: JSON.stringify({ ...payload, command: "execute" }) + }) + .promise(); + + return response.StatusCode; + } + + private getResult(response: MigrationStatusResponse | MigrationInvocationErrorResponse) { + return new MigrationRunnerResult(this.functionName, response); + } + + private async getStatus(payload: Record) { + const getStatus = () => { + return this.lambdaClient + .invoke({ + FunctionName: this.functionName, + InvocationType: "RequestResponse", + Payload: JSON.stringify({ ...payload, command: "status" }) + }) + .promise(); + }; + + const response = await executeWithRetry(getStatus); + + return JSON.parse(response.Payload as string) as MigrationEventHandlerResponse; + } + + private getMigrationStatusReportInterval() { + const envKey = "MIGRATION_STATUS_REPORT_INTERVAL"; + if (envKey in process.env) { + return parseInt(String(process.env[envKey])); + } + return 2000; + } +} diff --git a/packages/data-migration/src/cli/MigrationStatusReporter.ts b/packages/data-migration/src/cli/MigrationStatusReporter.ts new file mode 100644 index 00000000000..aa6463832da --- /dev/null +++ b/packages/data-migration/src/cli/MigrationStatusReporter.ts @@ -0,0 +1,5 @@ +import { MigrationStatus } from "~/types"; + +export interface MigrationStatusReporter { + report(status: MigrationStatus): void; +} diff --git a/packages/data-migration/src/cli/NonInteractiveCliStatusReporter.ts b/packages/data-migration/src/cli/NonInteractiveCliStatusReporter.ts new file mode 100644 index 00000000000..e9a1be9fc71 --- /dev/null +++ b/packages/data-migration/src/cli/NonInteractiveCliStatusReporter.ts @@ -0,0 +1,42 @@ +import { MigrationStatusReporter } from "~/cli/MigrationStatusReporter"; +import { MigrationStatus } from "~/types"; +import { LogReporter } from "~/cli/LogReporter"; + +export class NonInteractiveCliStatusReporter implements MigrationStatusReporter { + private logReporter: LogReporter; + private firstCall = true; + + constructor(logReporter: LogReporter) { + this.logReporter = logReporter; + console.log(`Using "NonInteractiveCliStatusReporter".`); + } + + async report(migrationStatus: MigrationStatus) { + const { status, context } = migrationStatus; + + const currentLogStreamName = context?.logStreamName; + + if (currentLogStreamName) { + this.logReporter.initializeStream(currentLogStreamName); + if (this.firstCall) { + this.logReporter.printLogStreamLinks(); + process.stdout.write(`\n---------- MIGRATION LOGS START ----------\n\n`); + } + await this.logReporter.printLogs(currentLogStreamName); + } + + if (["done", "error"].includes(status)) { + // We want to give AWS some time for the latest log events to become available. + await new Promise(resolve => { + setTimeout(resolve, 10000); + }); + + if (currentLogStreamName) { + await this.logReporter.printLogs(currentLogStreamName); + process.stdout.write(`\n---------- MIGRATION LOGS END ----------\n`); + } + } + + this.firstCall = false; + } +} diff --git a/packages/data-migration/src/cli/VoidStatusReporter.ts b/packages/data-migration/src/cli/VoidStatusReporter.ts new file mode 100644 index 00000000000..ece9fe7ea00 --- /dev/null +++ b/packages/data-migration/src/cli/VoidStatusReporter.ts @@ -0,0 +1,7 @@ +import { MigrationStatusReporter } from "./MigrationStatusReporter"; + +export class VoidStatusReporter implements MigrationStatusReporter { + report(): void { + // This is a void reporter. + } +} diff --git a/packages/data-migration/src/cli/getMigrationStatus.ts b/packages/data-migration/src/cli/getMigrationStatus.ts deleted file mode 100644 index 82554128e49..00000000000 --- a/packages/data-migration/src/cli/getMigrationStatus.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { executeWithRetry } from "@webiny/utils"; -import LambdaClient from "aws-sdk/clients/lambda"; -import { MigrationEventHandlerResponse } from "~/types"; - -interface GetMigrationStatusParams { - lambdaClient: LambdaClient; - functionName: string; - payload?: Record; -} - -export const getMigrationStatus = async ({ - payload, - functionName, - lambdaClient -}: GetMigrationStatusParams) => { - const getStatus = () => { - return lambdaClient - .invoke({ - FunctionName: functionName, - InvocationType: "RequestResponse", - Payload: JSON.stringify({ ...payload, command: "status" }) - }) - .promise(); - }; - - const response = await executeWithRetry(getStatus); - - return JSON.parse(response.Payload as string) as MigrationEventHandlerResponse; -}; diff --git a/packages/data-migration/src/cli/index.ts b/packages/data-migration/src/cli/index.ts index fc5c6a7004d..cd9c67861ee 100644 --- a/packages/data-migration/src/cli/index.ts +++ b/packages/data-migration/src/cli/index.ts @@ -1,3 +1,9 @@ -export * from "./printReport"; -export * from "./runMigration"; export * from "./getDuration"; +export * from "./MigrationRunner"; +export * from "./MigrationStatusReporter"; +export * from "./MigrationRunReporter"; +export * from "./InteractiveCliStatusReporter"; +export * from "./NonInteractiveCliStatusReporter"; +export * from "./CliMigrationRunReporter"; +export * from "./LogStream"; +export * from "./LogReporter"; diff --git a/packages/data-migration/src/cli/printReport.ts b/packages/data-migration/src/cli/printReport.ts deleted file mode 100644 index 33e7210c5b6..00000000000 --- a/packages/data-migration/src/cli/printReport.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { CliContext } from "@webiny/cli/types"; -import { MigrationEventHandlerResponse, MigrationInvocationErrorResponse } from "~/types"; -import center from "center-align"; - -interface ReportParams { - response: MigrationEventHandlerResponse; - migrationLambdaArn: string; - context: CliContext; -} - -const isError = ( - response: MigrationEventHandlerResponse -): response is MigrationInvocationErrorResponse => { - if (!response) { - return false; - } - - return "error" in response; -}; - -const makeEven = (str: string) => { - if (str.length % 2 > 0) { - return str + " "; - } - return str; -}; - -export const printReport = ({ response, migrationLambdaArn, context }: ReportParams) => { - if (!response) { - return; - } - - if (isError(response)) { - context.error(response.error.message); - return; - } - - const functionName = migrationLambdaArn.split(":").pop(); - context.success(`Data migration Lambda %s executed successfully!`, functionName); - - const { migrations, ...run } = response.data; - if (!migrations.length) { - context.info(`No applicable migrations were found!`); - return; - } - - const maxLength = Math.max(...migrations.map(mig => mig.status.length)) + 2; - context.info(`Migration run: %s`, run.id); - context.info(`Status: %s`, run.status); - context.info(`Started on: %s`, run.startedOn); - if (run.status === "done") { - context.info(`Finished on: %s`, run.finishedOn); - } - for (const migration of migrations) { - context.info( - ...[ - `[%s] %s: ${migration.description}`, - center(makeEven(migration.status), maxLength), - migration.id - ] - ); - } -}; diff --git a/packages/data-migration/src/cli/runMigration.ts b/packages/data-migration/src/cli/runMigration.ts deleted file mode 100644 index 0398703a15e..00000000000 --- a/packages/data-migration/src/cli/runMigration.ts +++ /dev/null @@ -1,96 +0,0 @@ -import LambdaClient from "aws-sdk/clients/lambda"; -import { - MigrationEventHandlerResponse, - MigrationInvocationErrorResponse, - MigrationRun, - MigrationStatus, - MigrationStatusResponse -} from "~/types"; -import { getMigrationStatus } from "./getMigrationStatus"; - -interface RunMigrationParams { - lambdaClient: LambdaClient; - functionName: string; - payload?: Record; - statusCallback?: (status: MigrationRun) => void; -} - -const getMigrationStatusReportInterval = () => { - const envKey = "MIGRATION_STATUS_REPORT_INTERVAL"; - if (envKey in process.env) { - return parseInt(String(process.env[envKey])); - } - return 2000; -}; - -/** - * Run the migration Lambda, and re-run when resuming is requested. - */ -export const runMigration = async ({ - payload, - functionName, - lambdaClient, - statusCallback -}: RunMigrationParams): Promise => { - // We don't report status, if `stdout` is not TTY (usually in CIs, and child processes spawned programmatically). - const reportStatus = (data: MigrationStatus) => { - if (!process.stdout.isTTY || typeof statusCallback !== "function") { - return; - } - - statusCallback(data); - }; - - const invokeMigration = async () => { - const response = await lambdaClient - .invoke({ - FunctionName: functionName, - InvocationType: "Event", - Payload: JSON.stringify({ ...payload, command: "execute" }) - }) - .promise(); - - return response.StatusCode; - }; - - // Execute migration function. - await invokeMigration(); - - // Poll for status and re-execute when migration is in "pending" state. - let response: MigrationEventHandlerResponse; - while (true) { - await new Promise(resolve => setTimeout(resolve, getMigrationStatusReportInterval())); - - response = await getMigrationStatus({ - payload, - functionName, - lambdaClient - }); - - if (!response) { - continue; - } - - const { data, error } = response; - - // If we received an error, it must be an unrecoverable error, and we don't retry. - if (error) { - return response; - } - - switch (data.status) { - case "init": - reportStatus(data); - continue; - case "pending": - await invokeMigration(); - break; - case "running": - reportStatus(data); - break; - case "done": - default: - return response; - } - } -}; diff --git a/packages/data-migration/src/handlers/createDdbEsProjectMigration.ts b/packages/data-migration/src/handlers/createDdbEsProjectMigration.ts index 667e048958f..d98ffe1e2ce 100644 --- a/packages/data-migration/src/handlers/createDdbEsProjectMigration.ts +++ b/packages/data-migration/src/handlers/createDdbEsProjectMigration.ts @@ -81,6 +81,10 @@ export const createDdbEsProjectMigration = ({ // Inject dependencies and execute. try { const runner = await container.resolve(MigrationRunner); + runner.setContext({ + logGroupName: process.env.AWS_LAMBDA_LOG_GROUP_NAME, + logStreamName: process.env.AWS_LAMBDA_LOG_STREAM_NAME + }); if (payload.command === "execute") { await runner.execute(projectVersion, patternMatcher || isMigrationApplicable); diff --git a/packages/data-migration/src/handlers/createDdbProjectMigration.ts b/packages/data-migration/src/handlers/createDdbProjectMigration.ts index 098690889e9..3f77d830ed1 100644 --- a/packages/data-migration/src/handlers/createDdbProjectMigration.ts +++ b/packages/data-migration/src/handlers/createDdbProjectMigration.ts @@ -72,13 +72,19 @@ export const createDdbProjectMigration = ({ // Inject dependencies and execute. try { const runner = await container.resolve(MigrationRunner); + runner.setContext({ + logGroupName: process.env.AWS_LAMBDA_LOG_GROUP_NAME, + logStreamName: process.env.AWS_LAMBDA_LOG_STREAM_NAME + }); if (payload.command === "execute") { await runner.execute(projectVersion, patternMatcher || isMigrationApplicable); return; } - return { data: await runner.getStatus() }; + return { + data: await runner.getStatus() + }; } catch (err) { return { error: { message: err.message } }; } diff --git a/packages/data-migration/src/types.ts b/packages/data-migration/src/types.ts index ceda155c608..ef582a7eeff 100644 --- a/packages/data-migration/src/types.ts +++ b/packages/data-migration/src/types.ts @@ -16,6 +16,7 @@ export interface MigrationRun { finishedOn: string; status: "init" | "running" | "pending" | "done" | "error"; migrations: MigrationRunItem[]; + context?: Record; error?: { message: string; name?: string; @@ -46,6 +47,7 @@ export interface DataMigrationContext { projectVersion: string; logger: Logger; checkpoint?: TCheckpoint; + forceExecute: boolean; runningOutOfTime: () => boolean; createCheckpoint: (data: TCheckpoint) => void; createCheckpointAndExit: (data: TCheckpoint) => void; diff --git a/packages/handler/__tests__/caching.test.ts b/packages/handler/__tests__/caching.test.ts new file mode 100644 index 00000000000..f290a0ca2a0 --- /dev/null +++ b/packages/handler/__tests__/caching.test.ts @@ -0,0 +1,100 @@ +import { createHandler } from "~/fastify"; +import { DummyCache } from "./caching/DummyCache"; +import { createCachingPlugin } from "./caching/cachePlugin"; +import { createPostRoute } from "./caching/createPostRoute"; +import { createRequestBody, createRequestBodyValue } from "./caching/requestBody"; + +describe("request caching", () => { + it.skip("should store the request response", async () => { + const cache = new DummyCache(); + const app = createHandler({ + plugins: [createPostRoute(), createCachingPlugin(cache)] + }); + + const result = await app.inject(createRequestBody()); + expect(result).toMatchObject({ + statusCode: 200, + body: JSON.stringify({ + aResponseValue: 1 + }) + }); + const values = await cache.getData(); + + const value = Object.values(values).shift(); + expect(value!.reads).toEqual(0); + expect(value!.value.body).toEqual(createRequestBodyValue()); + expect(value!.value.payload).toEqual(JSON.stringify({ aResponseValue: 1 })); + }); + + it("should retrieve the request response from cache", async () => { + const cache = new DummyCache(); + /** + * First request. + */ + const firstRequestApp = createHandler({ + plugins: [createPostRoute(), createCachingPlugin(cache)] + }); + const firstRequestResponse = await firstRequestApp.inject(createRequestBody()); + expect(firstRequestResponse).toMatchObject({ + statusCode: 200, + body: JSON.stringify({ + aResponseValue: 1 + }) + }); + + const firstCacheValues = await cache.getData(); + + const firstCacheValue = Object.values(firstCacheValues).shift(); + expect(firstCacheValue!.reads).toEqual(0); + expect(firstCacheValue!.value.body).toEqual(createRequestBodyValue()); + expect(firstCacheValue!.value.payload).toEqual(JSON.stringify({ aResponseValue: 1 })); + /** + * Second request. + * In the second request we return whole cache data item. + */ + const secondRequestApp = createHandler({ + plugins: [createPostRoute(), createCachingPlugin(cache)] + }); + const secondRequestResponse = await secondRequestApp.inject(createRequestBody()); + /** + * To check that everything is ok, we need to compare the request body value and stringified payload value - and stringify it all together again. + * This is only to make sure that the cache is working as expected. + */ + expect(secondRequestResponse).toMatchObject({ + statusCode: 200, + body: JSON.stringify({ + body: createRequestBodyValue(), + payload: JSON.stringify({ + aResponseValue: 1 + }) + }) + }); + + const secondCacheValues = await cache.getData(); + + const secondCacheValue = Object.values(secondCacheValues).shift(); + expect(secondCacheValue!.reads).toEqual(1); + expect(secondCacheValue!.value.payload).toEqual(JSON.stringify({ aResponseValue: 1 })); + + /** + * A third request, goes to the first request app. + */ + const thirdRequestResponse = await firstRequestApp.inject(createRequestBody()); + expect(thirdRequestResponse).toMatchObject({ + statusCode: 200, + body: JSON.stringify({ + body: createRequestBodyValue(), + payload: JSON.stringify({ + aResponseValue: 1 + }) + }) + }); + + const thirdCacheValues = await cache.getData(); + + const thirdCacheValue = Object.values(thirdCacheValues).shift(); + expect(thirdCacheValue!.reads).toEqual(2); + expect(thirdCacheValue!.value.body).toEqual(createRequestBodyValue()); + expect(thirdCacheValue!.value.payload).toEqual(JSON.stringify({ aResponseValue: 1 })); + }); +}); diff --git a/packages/handler/__tests__/caching/DummyCache.ts b/packages/handler/__tests__/caching/DummyCache.ts new file mode 100644 index 00000000000..6ff09963887 --- /dev/null +++ b/packages/handler/__tests__/caching/DummyCache.ts @@ -0,0 +1,41 @@ +interface CacheValue { + body: any; + payload: string; +} + +interface CacheDataItem { + reads: number; + value: CacheValue; +} + +interface CacheData { + [key: string]: CacheDataItem; +} + +export class DummyCache { + private data: CacheData = {}; + + public async store(key: string, value: CacheValue): Promise { + this.data[key] = { + reads: 0, + value + }; + } + + public async read(key: string): Promise { + const data = this.data[key] || null; + if (!data) { + return null; + } + this.markRead(key); + return data; + } + + public async getData(): Promise { + return this.data; + } + + private markRead(key: string): void { + this.data[key].reads++; + } +} diff --git a/packages/handler/__tests__/caching/cachePlugin.ts b/packages/handler/__tests__/caching/cachePlugin.ts new file mode 100644 index 00000000000..bdf9cd08493 --- /dev/null +++ b/packages/handler/__tests__/caching/cachePlugin.ts @@ -0,0 +1,37 @@ +import { createModifyFastifyPlugin } from "~/plugins/ModifyFastifyPlugin"; +import { DummyCache } from "./DummyCache"; +import { hash } from "./hash"; + +export const createCachingPlugin = (cache: DummyCache) => { + return createModifyFastifyPlugin(app => { + /** + * When receiving a request, we check if we have a response in cache. + */ + app.addHook("preValidation", async (request, reply) => { + const key = hash(request.body); + const value = await cache.read(key); + if (!value) { + return; + } + return reply.status(200).header("x-using-cache", true).send(value.value).hijack(); + }); + /** + * When sending a response, we store the response in cache. + */ + app.addHook("onSend", async (request, reply, payload: string) => { + if ( + reply.statusCode !== 200 || + reply.getHeader("x-using-cache") || + request.method !== "POST" + ) { + return; + } + + const key = hash(request.body); + await cache.store(key, { + body: request.body, + payload + }); + }); + }); +}; diff --git a/packages/handler/__tests__/caching/createPostRoute.ts b/packages/handler/__tests__/caching/createPostRoute.ts new file mode 100644 index 00000000000..e80994ccd80 --- /dev/null +++ b/packages/handler/__tests__/caching/createPostRoute.ts @@ -0,0 +1,11 @@ +import { createRoute } from "~/plugins/RoutePlugin"; + +export const createPostRoute = () => { + return createRoute(({ onPost }) => { + onPost("/webiny-post", async (_, reply) => { + return reply.code(200).send({ + aResponseValue: 1 + }); + }); + }); +}; diff --git a/packages/handler/__tests__/caching/hash.ts b/packages/handler/__tests__/caching/hash.ts new file mode 100644 index 00000000000..b6ceb902534 --- /dev/null +++ b/packages/handler/__tests__/caching/hash.ts @@ -0,0 +1,6 @@ +import crypto from "crypto"; + +export const hash = (input: any): string => { + const value = typeof input === "object" && input ? JSON.stringify(input) : String(input); + return crypto.createHash("sha256").update(value).digest("hex"); +}; diff --git a/packages/handler/__tests__/caching/requestBody.ts b/packages/handler/__tests__/caching/requestBody.ts new file mode 100644 index 00000000000..49c9beb96e4 --- /dev/null +++ b/packages/handler/__tests__/caching/requestBody.ts @@ -0,0 +1,19 @@ +import { InjectOptions } from "fastify"; + +export const createRequestBodyValue = () => { + return { + name: "John", + lastName: "Doe" + }; +}; +export const createRequestBody = (): InjectOptions => { + return { + path: "/webiny-post", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + query: {}, + payload: JSON.stringify(createRequestBodyValue()) + }; +}; diff --git a/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts b/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts index 9ecbb60cd09..acbe49f2291 100644 --- a/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts +++ b/packages/migrations/__tests__/migrations/5.36.0/001/ddb/001.test.ts @@ -314,4 +314,33 @@ describe("5.36.0-001", () => { expect(grouped.notApplicable.length).toBe(0); } }); + + it("should run the migration if forced via an ENV variable", async () => { + await insertTestData(table, [...createTenantsData(), ...createLocalesData()]); + await insertTestFiles(1); + + const handler = createDdbMigrationHandler({ table, migrations: [AcoRecords_5_36_0_001] }); + + // Should run the migration + { + process.stdout.write("[First run]\n"); + const { data, error } = await handler(); + assertNotError(error); + const grouped = groupMigrations(data.migrations); + expect(grouped.executed.length).toBe(1); + } + + // Should force-run the migration + { + // @ts-ignore + process.env["WEBINY_MIGRATION_FORCE_EXECUTE_5_36_0_001"] = "true"; + process.stdout.write("[Second run]\n"); + const { data, error } = await handler(); + assertNotError(error); + const grouped = groupMigrations(data.migrations); + expect(grouped.executed.length).toBe(1); + expect(grouped.skipped.length).toBe(0); + expect(grouped.notApplicable.length).toBe(0); + } + }); }); diff --git a/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts b/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts index f55ec797336..8c2ee96c83c 100644 --- a/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts +++ b/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts @@ -245,7 +245,7 @@ export class CmsEntriesRootFolder_5_37_0_002 `Skipping record "${esRecord.PK}" as it is not a valid CMS entry...` ); continue; - } else if (decompressedData.location?.folderId) { + } else if (!context.forceExecute && decompressedData.location?.folderId) { logger.trace( `Skipping record "${decompressedData.entryId}" as it already has folderId defined...` ); @@ -258,10 +258,12 @@ export class CmsEntriesRootFolder_5_37_0_002 folderId: "root" } }); + const modified = new Date().toISOString(); ddbEsItems.push( this.ddbEsEntryEntity.putBatch({ ...esRecord, - data: compressedData + data: compressedData, + modified }) ); } diff --git a/packages/migrations/src/utils/elasticsearch/esGetIndexName.ts b/packages/migrations/src/utils/elasticsearch/esGetIndexName.ts index dd2d133f352..e23b4d4e074 100644 --- a/packages/migrations/src/utils/elasticsearch/esGetIndexName.ts +++ b/packages/migrations/src/utils/elasticsearch/esGetIndexName.ts @@ -28,7 +28,7 @@ export const esGetIndexName = (params: EsGetIndexNameParams) => { const tenantId = sharedIndex ? "root" : tenant; let localeCode: string | null = null; - if (process.env.WEBINY_ELASTICSEARCH_INDEX_LOCALE === "true") { + if (isHeadlessCmsModel || process.env.WEBINY_ELASTICSEARCH_INDEX_LOCALE === "true") { if (!locale) { throw new WebinyError( `Missing "locale" parameter when trying to create Elasticsearch index name.`, diff --git a/packages/pulumi-aws/src/apps/tenantRouter.ts b/packages/pulumi-aws/src/apps/tenantRouter.ts index 3925185f7f6..27977ab21be 100644 --- a/packages/pulumi-aws/src/apps/tenantRouter.ts +++ b/packages/pulumi-aws/src/apps/tenantRouter.ts @@ -82,8 +82,7 @@ export function applyTenantRouter( } ] } - }, - meta: { isLambdaFunctionRole: true } + } }); const awsUsEast1 = new aws.Provider("us-east-1", { region: "us-east-1" }); @@ -108,7 +107,10 @@ export function applyTenantRouter( // the environment is destroyed. Users need to delete the function manually. We decided to use // this option here because it enables us to avoid annoying AWS Lambda function replication // errors upon destroying the stack (see https://github.com/pulumi/pulumi-aws/issues/2178). - opts: { provider: awsUsEast1, retainOnDelete: true } + opts: { provider: awsUsEast1, retainOnDelete: true }, + meta: { + canUseVpc: false + } }); cloudfront.config.defaultCacheBehavior(value => { diff --git a/packages/pulumi-aws/src/enterprise/createApiPulumiApp.ts b/packages/pulumi-aws/src/enterprise/createApiPulumiApp.ts index 5f880c4ddaf..15c9f361df4 100644 --- a/packages/pulumi-aws/src/enterprise/createApiPulumiApp.ts +++ b/packages/pulumi-aws/src/enterprise/createApiPulumiApp.ts @@ -49,7 +49,10 @@ export function createApiPulumiApp(projectAppParams: CreateApiPulumiAppParams = onResource(resource => { if (isResourceOfType(resource, aws.lambda.Function)) { - resource.config.vpcConfig(useExistingVpc!.lambdaFunctionsVpcConfig); + const canUseVpc = resource.meta.canUseVpc !== false; + if (canUseVpc) { + resource.config.vpcConfig(useExistingVpc!.lambdaFunctionsVpcConfig); + } } if (isResourceOfType(resource, aws.iam.Role)) { diff --git a/packages/pulumi-aws/src/enterprise/createCorePulumiApp.ts b/packages/pulumi-aws/src/enterprise/createCorePulumiApp.ts index 571058a5530..1ba414ebcc8 100644 --- a/packages/pulumi-aws/src/enterprise/createCorePulumiApp.ts +++ b/packages/pulumi-aws/src/enterprise/createCorePulumiApp.ts @@ -76,7 +76,10 @@ export function createCorePulumiApp(projectAppParams: CreateCorePulumiAppParams onResource(resource => { if (isResourceOfType(resource, aws.lambda.Function)) { - resource.config.vpcConfig(useExistingVpc!.lambdaFunctionsVpcConfig); + const canUseVpc = resource.meta.canUseVpc !== false; + if (canUseVpc) { + resource.config.vpcConfig(useExistingVpc!.lambdaFunctionsVpcConfig); + } } if (isResourceOfType(resource, aws.iam.Role)) { diff --git a/packages/pulumi-aws/src/enterprise/createWebsitePulumiApp.ts b/packages/pulumi-aws/src/enterprise/createWebsitePulumiApp.ts index a745799e980..c870ec4f04d 100644 --- a/packages/pulumi-aws/src/enterprise/createWebsitePulumiApp.ts +++ b/packages/pulumi-aws/src/enterprise/createWebsitePulumiApp.ts @@ -49,7 +49,10 @@ export function createWebsitePulumiApp(projectAppParams: CreateWebsitePulumiAppP onResource(resource => { if (isResourceOfType(resource, aws.lambda.Function)) { - resource.config.vpcConfig(useExistingVpc!.lambdaFunctionsVpcConfig); + const canUseVpc = resource.meta.canUseVpc !== false; + if (canUseVpc) { + resource.config.vpcConfig(useExistingVpc!.lambdaFunctionsVpcConfig); + } } if (isResourceOfType(resource, aws.iam.Role)) { diff --git a/packages/react-rich-text-lexical-renderer/README.md b/packages/react-rich-text-lexical-renderer/README.md index 2c767c6fb86..674ce9dcb10 100644 --- a/packages/react-rich-text-lexical-renderer/README.md +++ b/packages/react-rich-text-lexical-renderer/README.md @@ -9,7 +9,8 @@ A React component to render lexical editor data coming from Webiny Headless CMS ## About -Webiny uses Lexical editor https://lexical.dev/ as a go to Rich Text Editor, with some additional plugins. To speed up the rendering of data for developers, we created this component. +Webiny uses Lexical editor https://lexical.dev/ as a go to Rich Text Editor, with some additional plugins. To speed up +the rendering of data for developers, we created this component. ## Install @@ -28,7 +29,7 @@ yarn add @webiny/react-rich-text-lexical-renderer Fetch your data from Headless CMS, then pass it to the component like this: ```tsx -import { RichTextRenderer } from "@webiny/react-rich-text-renderer"; +import {RichTextRenderer} from "@webiny/react-rich-text-renderer"; // Load content from Headless CMS (here we show what your content might look like). const content = { @@ -55,16 +56,16 @@ const content = { version: 1 } ], - direction: "ltr", - format: "", - indent: 0, - type: "root", - version: 1 + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1 } } // Mount the component -; +; ``` ## Adding your custom lexical nodes for rendering @@ -74,18 +75,22 @@ You can add custom lexical nodes for rendering your content: ```tsx class MyCustomNode extends LexicalNode { - ... +... } // Mount the component -; +; ``` ## Adding your custom typography theme. -You can override Webiny default typography theme that is used by lexical editor by providing your custom typography object. +You can override Webiny default typography theme that is used by lexical editor by providing your custom typography +object. + +Please [ read our docs ](https://www.webiny.com/docs/page-builder/theming/theme-object) and check +our [theme object on GitHub](hhttps://github.com/webiny/webiny-js/blob/v5.35.0/packages/cwp-template-aws/template/common/apps/theme/theme.ts) +before add you custom theme. -Please [ read our docs ](https://www.webiny.com/docs/page-builder/theming/theme-object) and check our [theme object on GitHub](hhttps://github.com/webiny/webiny-js/blob/v5.35.0/packages/cwp-template-aws/template/common/apps/theme/theme.ts) before add you custom theme. ```tsx const myTheme = { @@ -96,12 +101,70 @@ const myTheme = { id: "custom_heading1", name: "Custom Heading 1", tag: "h1", - styles: { ...headings, fontWeight: "bold", fontSize: 48 } + styles: {...headings, fontWeight: "bold", fontSize: 48} }] } } } // Mount the component -; +; +``` + +## Resolve the mismatch of the versions in the React v18 application + +When you try to use `RichTextLexicalRenderer` component in React `v18` application you will see this error on the +screen: + +![React application error for mismatch of the React versions](./images/react-renderer-versisons-conflict-error.png) + +This is because our `@webiny/react-rich-text-lexical-renderer` package and the React application have +different versions of React. Our rich text renderer component is using `v17.0.2`, and the React application is +using `v18.x.x`. + +> You can check which React versions are requested by various dependencies by running the following command: +> - `yarn why react` for `yarn` users. +> - `npm ls react` for `npm` users. + +To resolve this problem, we need to force all dependencies to use the same version of React. + +### Instructions for `yarn` users + +To force `yarn` to resolve dependencies across the project to the exact versions we're looking for, use +the `resolutions` field in the root `package.json` file. + +```json package.json +{ + ... + "resolutions": { + "react": "18.x.x" + }, + ... +} ``` + +Once the `resolutions` field is defined, run `yarn` to apply the new config. + +To learn more about the `resolutions` field, please check +this [yarn documentation article](https://classic.yarnpkg.com/lang/en/docs/selective-version-resolutions/). + +### Instructions for `npm` users + +The `npm` supports the same functionality as `yarn` with the `overrides` field name. You need to add `overrides` +field in `package.json` file. + +```json package.json +{ + ... + "overrides": { + "react": "^18.x.x" + }, + ... +} +``` + +Once the `overrides` field is defined, run `npm install` to apply the new config. + +To learn more about the `overrides` field, please check +this [npm documentation article](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides). + diff --git a/packages/react-rich-text-lexical-renderer/images/react-renderer-versisons-conflict-error.png b/packages/react-rich-text-lexical-renderer/images/react-renderer-versisons-conflict-error.png new file mode 100644 index 00000000000..3fcc3c1cc1e Binary files /dev/null and b/packages/react-rich-text-lexical-renderer/images/react-renderer-versisons-conflict-error.png differ diff --git a/packages/serverless-cms-aws/package.json b/packages/serverless-cms-aws/package.json index 5707d1c2c26..32554b7c5c4 100644 --- a/packages/serverless-cms-aws/package.json +++ b/packages/serverless-cms-aws/package.json @@ -56,10 +56,12 @@ "@webiny/pulumi": "0.0.0", "@webiny/pulumi-aws": "0.0.0", "@webiny/wcp": "0.0.0", + "chalk": "^4.1.0", "fast-glob": "^3.2.7", "find-up": "^5.0.0", "invariant": "^2.2.4", "node-fetch": "^2.6.1", + "ora": "4.1.1", "webpack": "^5.74.0" }, "devDependencies": { diff --git a/packages/serverless-cms-aws/src/api/plugins/executeDataMigrations.ts b/packages/serverless-cms-aws/src/api/plugins/executeDataMigrations.ts index 0ae95ea67a7..8aa4594a81f 100644 --- a/packages/serverless-cms-aws/src/api/plugins/executeDataMigrations.ts +++ b/packages/serverless-cms-aws/src/api/plugins/executeDataMigrations.ts @@ -1,15 +1,13 @@ -import readline from "readline"; import LambdaClient from "aws-sdk/clients/lambda"; import { CliContext } from "@webiny/cli/types"; import { getStackOutput } from "@webiny/cli-plugin-deploy-pulumi/utils"; -import { printReport, runMigration, getDuration } from "@webiny/data-migration/cli"; - -const clearLine = () => { - if (process.stdout.isTTY) { - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - } -}; +import { + LogReporter, + InteractiveCliStatusReporter, + NonInteractiveCliStatusReporter, + MigrationRunner, + CliMigrationRunReporter +} from "@webiny/data-migration/cli"; /** * On every deployment of the API project application, this plugin invokes the data migrations Lambda. @@ -42,34 +40,28 @@ export const executeDataMigrations = { region: apiOutput.region }); - const response = await runMigration({ - lambdaClient, - functionName: apiOutput["migrationLambdaArn"], - payload: { - version: process.env.WEBINY_VERSION || context.version - }, - statusCallback: ({ status, migrations }) => { - clearLine(); - if (status === "running") { - const currentMigration = migrations.find(mig => mig.status === "running"); - if (currentMigration) { - const duration = getDuration(currentMigration.startedOn as string); - process.stdout.write( - `Running data migration ${currentMigration.id} (${duration})...` - ); - } - return; - } + const functionName = apiOutput["migrationLambdaArn"]; - if (status === "init") { - process.stdout.write(`Checking data migrations...`); - } - } + const logReporter = new LogReporter(functionName); + const statusReporter = + !process.stdout.isTTY || "CI" in process.env + ? new NonInteractiveCliStatusReporter(logReporter) + : new InteractiveCliStatusReporter(logReporter); + + const runner = MigrationRunner.create({ + lambdaClient, + functionName, + statusReporter }); - clearLine(); + const result = await runner.runMigration({ + version: process.env.WEBINY_VERSION || context.version + }); - printReport({ response, context, migrationLambdaArn: apiOutput["migrationLambdaArn"] }); + if (result) { + const reporter = new CliMigrationRunReporter(logReporter, context); + await reporter.report(result); + } } catch (e) { context.error(`An error occurred while executing data migrations Lambda function!`); console.log(e); diff --git a/packages/serverless-cms-aws/src/core/plugins/checkEsServiceRole.ts b/packages/serverless-cms-aws/src/core/plugins/checkEsServiceRole.ts new file mode 100644 index 00000000000..6c5e9b8afd1 --- /dev/null +++ b/packages/serverless-cms-aws/src/core/plugins/checkEsServiceRole.ts @@ -0,0 +1,51 @@ +import IAM from "aws-sdk/clients/iam"; +import ora from "ora"; +import { green } from "chalk"; +import { CliContext } from "@webiny/cli/types"; + +const NO_SUCH_ENTITY_IAM_ERROR = "NoSuchEntity"; + +export const checkEsServiceRole = { + type: "hook-before-deploy", + name: "hook-before-deploy-es-service-role", + async hook(params: Record, context: CliContext) { + const spinner = ora(); + spinner.start(`Checking Elastic Search service role...`); + const iam = new IAM(); + try { + await iam + .getRole({ RoleName: "AWSServiceRoleForAmazonElasticsearchService" }) + .promise(); + + spinner.stopAndPersist({ + symbol: green("✔"), + text: `Found Elastic Search service role!` + }); + context.success(`Found Elastic Search service role!`); + } catch (err) { + // We've seen cases where the `iam.getRole` call fails because of an issue + // other than not being able to retrieve the service role. Let's print + // additional info if that's the case. Will make debugging a bit easier. + if (err.code !== NO_SUCH_ENTITY_IAM_ERROR) { + spinner.fail( + "Tried retrieving Elastic Search service role but failed with the following error: " + + err.message + ); + context.debug(err); + process.exit(1); + } + + spinner.text = "Creating Elastic Search service role..."; + + try { + await iam.createServiceLinkedRole({ AWSServiceName: "es.amazonaws.com" }).promise(); + + spinner.stop(); + } catch (err) { + spinner.fail(err.message); + context.debug(err); + process.exit(1); + } + } + } +}; diff --git a/packages/serverless-cms-aws/src/core/plugins/index.ts b/packages/serverless-cms-aws/src/core/plugins/index.ts index 82dee071b63..7031147e312 100644 --- a/packages/serverless-cms-aws/src/core/plugins/index.ts +++ b/packages/serverless-cms-aws/src/core/plugins/index.ts @@ -1 +1,2 @@ export * from "./generateDdbToEsHandler"; +export * from "./checkEsServiceRole"; diff --git a/packages/serverless-cms-aws/src/createCoreApp.ts b/packages/serverless-cms-aws/src/createCoreApp.ts index 992a8eb895c..45c9cc70d32 100644 --- a/packages/serverless-cms-aws/src/createCoreApp.ts +++ b/packages/serverless-cms-aws/src/createCoreApp.ts @@ -1,6 +1,6 @@ import { createCorePulumiApp, CreateCorePulumiAppParams } from "@webiny/pulumi-aws"; import { PluginCollection } from "@webiny/plugins/types"; -import { generateDdbToEsHandler } from "./core/plugins"; +import { generateDdbToEsHandler, checkEsServiceRole } from "./core/plugins"; export { CoreOutput } from "@webiny/pulumi-aws"; @@ -11,7 +11,7 @@ export interface CreateCoreAppParams extends CreateCorePulumiAppParams { export function createCoreApp(projectAppParams: CreateCoreAppParams = {}) { const builtInPlugins = []; if (projectAppParams.elasticSearch) { - builtInPlugins.push(generateDdbToEsHandler); + builtInPlugins.push(generateDdbToEsHandler, checkEsServiceRole); } const customPlugins = projectAppParams.plugins ? [...projectAppParams.plugins] : []; diff --git a/packages/serverless-cms-aws/src/enterprise/createCoreApp.ts b/packages/serverless-cms-aws/src/enterprise/createCoreApp.ts index bb7f8662cd9..79a7ceda935 100644 --- a/packages/serverless-cms-aws/src/enterprise/createCoreApp.ts +++ b/packages/serverless-cms-aws/src/enterprise/createCoreApp.ts @@ -1,6 +1,6 @@ import { createCorePulumiApp, CreateCorePulumiAppParams } from "@webiny/pulumi-aws/enterprise"; import { PluginCollection } from "@webiny/plugins/types"; -import { generateDdbToEsHandler } from "~/core/plugins"; +import { generateDdbToEsHandler, checkEsServiceRole } from "~/core/plugins"; export { CoreOutput } from "@webiny/pulumi-aws"; @@ -11,7 +11,7 @@ export interface CreateCoreAppParams extends CreateCorePulumiAppParams { export function createCoreApp(projectAppParams: CreateCoreAppParams = {}) { const builtInPlugins = []; if (projectAppParams.elasticSearch) { - builtInPlugins.push(generateDdbToEsHandler); + builtInPlugins.push(generateDdbToEsHandler, checkEsServiceRole); } const customPlugins = projectAppParams.plugins ? [...projectAppParams.plugins] : []; diff --git a/packages/ui/src/Input/Input.tsx b/packages/ui/src/Input/Input.tsx index 9beae159b72..d867d7d63d6 100644 --- a/packages/ui/src/Input/Input.tsx +++ b/packages/ui/src/Input/Input.tsx @@ -148,7 +148,7 @@ export const Input: React.FC = props => { onBlur={onBlur} label={label} icon={icon} - placeholder={(!label && placeholder) || undefined} + placeholder={placeholder} trailingIcon={trailingIcon} rows={rows} className={classNames( diff --git a/yarn.lock b/yarn.lock index 5f6c3ef5624..126c451aac1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13339,6 +13339,7 @@ __metadata: "@webiny/api-headless-cms": 0.0.0 "@webiny/api-i18n": 0.0.0 "@webiny/api-mailer": 0.0.0 + "@webiny/api-page-builder": 0.0.0 "@webiny/api-security": 0.0.0 "@webiny/api-tenancy": 0.0.0 "@webiny/api-wcp": 0.0.0 @@ -15108,6 +15109,7 @@ __metadata: "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/react-composition": 0.0.0 + "@webiny/react-properties": 0.0.0 "@webiny/react-router": 0.0.0 "@webiny/telemetry": 0.0.0 "@webiny/ui": 0.0.0 @@ -15130,6 +15132,7 @@ __metadata: is-hotkey: ^0.1.3 lodash: ^4.17.10 medium-editor: ^5.23.3 + mobx-react-lite: ^3.4.3 nanoid: ^3.1.20 platform: ^1.3.5 prop-types: ^15.7.2 @@ -15890,7 +15893,6 @@ __metadata: inquirer: 7.3.3 load-json-file: 6.2.0 lodash: ^4.17.20 - ora: 4.1.1 write-json-file: 4.3.0 languageName: unknown linkType: soft @@ -16684,10 +16686,12 @@ __metadata: "@webiny/pulumi": 0.0.0 "@webiny/pulumi-aws": 0.0.0 "@webiny/wcp": 0.0.0 + chalk: ^4.1.0 fast-glob: ^3.2.7 find-up: ^5.0.0 invariant: ^2.2.4 node-fetch: ^2.6.1 + ora: 4.1.1 ttypescript: ^1.5.12 typescript: 4.7.4 webpack: ^5.74.0