diff --git a/.github/actions/core-cicd/api-limits-check/Readme.md b/.github/actions/core-cicd/api-limits-check/Readme.md new file mode 100644 index 00000000000..e5c7e8823e2 --- /dev/null +++ b/.github/actions/core-cicd/api-limits-check/Readme.md @@ -0,0 +1,32 @@ +# Check GitHub API Rate Limit Action + +This GitHub Action allows you to check the current API rate limits for your GitHub account by querying the `/rate_limit` endpoint of the GitHub API. The action outputs the full JSON response, including rate limits for core, search, GraphQL, and other GitHub API resources. + +## Inputs + +| Input | Description | Required | Default | +|--------|-------------|----------|---------| +| `token` | The GitHub token to authenticate the API request. | true | `${{ github.token }}` | + +## Outputs + +The action outputs the full JSON response from the `/rate_limit` endpoint directly to the workflow log. + +## Usage + +Here’s an example of how to use this action in a GitHub workflow: + +```yaml +name: Check GitHub API Rate Limits + +on: + workflow_dispatch: + +jobs: + check-rate-limits: + runs-on: ubuntu-latest + steps: + - name: Check API Rate Limit + uses: your-repo/check-rate-limit-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/actions/core-cicd/api-limits-check/action.yml b/.github/actions/core-cicd/api-limits-check/action.yml new file mode 100644 index 00000000000..fbbe8e671f4 --- /dev/null +++ b/.github/actions/core-cicd/api-limits-check/action.yml @@ -0,0 +1,15 @@ +name: 'Check GitHub API Rate Limit' +description: 'Outputs information on GitHub API /rate_limit endpoint' + +inputs: + token: + description: 'GitHub token to authenticate the API request' + required: true + default: ${{ github.token }} + +runs: + using: "composite" + steps: + - run: | + curl -s -H "Authorization: token ${{ inputs.token }}" https://api.github.com/rate_limit + shell: bash diff --git a/.github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml b/.github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml index 8640fce85d5..0c6bc0e64f2 100644 --- a/.github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml +++ b/.github/actions/core-cicd/deployment/deploy-javascript-sdk/action.yml @@ -196,7 +196,6 @@ runs: shell: bash - name: 'Publishing sdk into NPM registry' - if: ${{ steps.validate_version.outputs.publish == 'true' }} working-directory: ${{ github.workspace }}/core-web/libs/sdk/ env: NEXT_VERSION: ${{ steps.next_version.outputs.next_version }} diff --git a/.github/workflows/cicd_comp_finalize-phase.yml b/.github/workflows/cicd_comp_finalize-phase.yml index 292519e1583..2ce0b9b5a24 100644 --- a/.github/workflows/cicd_comp_finalize-phase.yml +++ b/.github/workflows/cicd_comp_finalize-phase.yml @@ -183,4 +183,12 @@ jobs: if [ "${{ needs.prepare-report-data.outputs.aggregate_status }}" != "SUCCESS" ]; then echo "One or more jobs failed or cancelled!" exit 1 - fi \ No newline at end of file + fi + + # Check can be removed if we have resolved root cause + # We cannot use a local github action for this as it is run before we checkout the repo + # secrets.GITHUB_TOKEN is not available in composite workflows so it needs to be passed in. + - name: Check API Rate Limit + shell: bash + run: | + curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN}}" https://api.github.com/rate_limit || true \ No newline at end of file diff --git a/.github/workflows/cicd_comp_initialize-phase.yml b/.github/workflows/cicd_comp_initialize-phase.yml index afe2267b4dc..f93f00a1e16 100644 --- a/.github/workflows/cicd_comp_initialize-phase.yml +++ b/.github/workflows/cicd_comp_initialize-phase.yml @@ -64,6 +64,13 @@ jobs: shell: bash run: | echo "Initializing..." + # Check can be removed if we have resolved root cause + # We cannot use a local github action for this as it is run before we checkout the repo + # secrets.GITHUB_TOKEN is not available in composite workflows so it needs to be passed in. + - name: Check API Rate Limit + shell: bash + run: | + curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/rate_limit || true # This job checks for artifacts from previous builds and determines if they can be reused check-previous-build: @@ -123,17 +130,21 @@ jobs: steps: - uses: actions/checkout@v4 if: ${{ inputs.validation-level != 'none' }} + + # Execute the paths-filter step to determine changes - uses: dorny/paths-filter@v3.0.1 if: ${{ inputs.validation-level != 'none' }} id: filter with: filters: .github/filters.yaml list-files: 'escape' + - name: Rewrite Filter id: filter-rewrite env: CICD_SKIP_TESTS: ${{ vars.CICD_SKIP_TESTS }} run: | + echo "::group::Rewrite Fiter" # Default action outcomes based on paths-filter action outputs frontend=${{ steps.filter.outputs.frontend || 'true'}} cli=${{ steps.filter.outputs.cli || 'true' }} @@ -145,7 +156,7 @@ jobs: # Check if the commit is to the master branch skip_tests=${CICD_SKIP_TESTS:-false} # Use environment variable, default to 'false' - # The below line ensures that if skip_tests is true, all tests are set to false. + # If skip_tests is true, set all tests to false if [ "$skip_tests" == "true" ]; then echo "Skipping tests as per CICD_SKIP_TESTS flag." frontend=false @@ -161,7 +172,7 @@ jobs: backend=false build=false jvm_unit_test=false - sdk_libs=false + IFS=',' read -r -a custom_modules_list <<< "${{ inputs.custom-modules }}" for module in "${custom_modules_list[@]}"; do if [ "${module}" == "frontend" ]; then @@ -175,7 +186,7 @@ jobs: elif [ "${module}" == "jvm_unit_test" ]; then jvm_unit_test=${{ steps.filter.outputs.jvm_unit_test }} elif [ "${module}" == "sdk_libs" ]; then - sdk=${{ steps.filter.outputs.sdk_libs }} + sdk_libs=${sdk_libs} fi done fi @@ -193,4 +204,5 @@ jobs: echo "backend=${backend}" >> $GITHUB_OUTPUT echo "build=${build}" >> $GITHUB_OUTPUT echo "jvm_unit_test=${jvm_unit_test}" >> $GITHUB_OUTPUT - echo "sdk_libs=${sdk_libs}" >> $GITHUB_OUTPUT \ No newline at end of file + echo "sdk_libs=${sdk_libs}" >> $GITHUB_OUTPUT + echo "::endgroup::" \ No newline at end of file diff --git a/.github/workflows/cicd_comp_pr-notifier.yml b/.github/workflows/cicd_comp_pr-notifier.yml index 5188eff9ed6..29c8da8709f 100644 --- a/.github/workflows/cicd_comp_pr-notifier.yml +++ b/.github/workflows/cicd_comp_pr-notifier.yml @@ -89,3 +89,10 @@ jobs: -d "{ \"channel\":\"${channel}\",\"text\":\"${message}\"}" \ -s \ https://slack.com/api/chat.postMessage + # Check can be removed if we have resolved root cause + # We cannot use a local github action for this as it is run before we checkout the repo + # secrets.GITHUB_TOKEN is not available in composite workflows so it needs to be passed in. + - name: Check API Rate Limit + shell: bash + run: | + curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/rate_limit || true \ No newline at end of file diff --git a/.github/workflows/cicd_comp_test-phase.yml b/.github/workflows/cicd_comp_test-phase.yml index 3501a40d643..6225c9e0500 100644 --- a/.github/workflows/cicd_comp_test-phase.yml +++ b/.github/workflows/cicd_comp_test-phase.yml @@ -133,6 +133,8 @@ jobs: - { name: "MainSuite 1b", pathName: "mainsuite1b", maven_args: '-Dit.test=MainSuite1b' } - { name: "MainSuite 2a", pathName: "mainsuite2a", maven_args: '-Dit.test=MainSuite2a' } - { name: "MainSuite 2b", pathName: "mainsuite2b", maven_args: '-Dit.test=MainSuite2b' } + - { name: "Junit5 Suite 1", pathName: "junit5suite1", maven_args: '-Dit.test=Junit5Suite1' } + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/cicd_manual_publish-starter.yml b/.github/workflows/cicd_manual_publish-starter.yml index 10d2e1b89e5..d98a717c02a 100644 --- a/.github/workflows/cicd_manual_publish-starter.yml +++ b/.github/workflows/cicd_manual_publish-starter.yml @@ -57,12 +57,15 @@ jobs: -H "Authorization: Bearer $ACCESS_TOKEN" } mkdir -p starter && cd starter + DATE=$(date +'%Y%m%d') if [[ "$STARTER_TYPE" == "empty" ]]; then echo "::debug::Empty Starter: downloading from [${{ env.EMPTY_STARTER_URL }}/${{ env.DOWNLOAD_ENDPOINT }}]" - RESPONSE=$(download_starter ${{ env.EMPTY_STARTER_URL }} ${{ env.EMPTY_STARTER_TOKEN }} empty_$(date +'%Y%m%d').zip) + FILENAME="empty_${DATE}.zip" + RESPONSE=$(download_starter ${{ env.EMPTY_STARTER_URL }} ${{ env.EMPTY_STARTER_TOKEN }} $FILENAME) else echo "::debut::Full Starter: downloading from [${{ env.FULL_STARTER_URL }}/${{ env.DOWNLOAD_ENDPOINT }}]" - RESPONSE=$(download_starter ${{ env.FULL_STARTER_URL }} ${{ env.FULL_STARTER_TOKEN }} $(date +'%Y%m%d').zip) + FILENAME="${DATE}.zip" + RESPONSE=$(download_starter ${{ env.FULL_STARTER_URL }} ${{ env.FULL_STARTER_TOKEN }} $FILENAME) fi echo "::notice::Status Code: $RESPONSE" if [[ "$RESPONSE" != "200" ]]; then @@ -70,7 +73,7 @@ jobs: exit 1 fi ls -ltrh - # echo "::endgroup::" + echo "::endgroup::" - name: 'Upload artifacts' id: upload-artifacts @@ -149,22 +152,99 @@ jobs: echo "::notice::Changelog: ${{ github.event.inputs.changelog }}" echo "::endgroup::" - send-notification: - if: ${{ github.event.inputs.dry-run == 'false' }} + update-pom: + if: ${{ github.event.inputs.type == 'empty' && github.event.inputs.dry-run == false }} needs: [ deploy-artifacts ] runs-on: ubuntu-20.04 environment: trunk + outputs: + pull-request-url: ${{ steps.create-pull-request.outputs.pull-request-url }} + steps: + - uses: actions/checkout@v4 + + - name: 'Update pom.xml' + id: update-pom + working-directory: ${{ github.workspace }}/parent + env: + FILENAME: ${{ needs.deploy-artifacts.outputs.filename }} + run: | + echo "::group::Update pom.xml" + echo "Updating pom.xml" + + VERSION="${FILENAME%.*}" + + # Create an auxiliary branch for versioning updates + AUXILIARY_BRANCH=update-starter-version-${VERSION}-${{ github.run_id }} + + sed -i "s/.*<\/starter.deploy.version>/${VERSION}<\/starter.deploy.version>/" pom.xml + + POM=$(cat pom.xml) + echo "POM file: ${POM}" + + echo auxiliary-branch=${AUXILIARY_BRANCH} >> $GITHUB_OUTPUT + echo starter-version=${VERSION} >> $GITHUB_OUTPUT + echo "::notice::Auxiliary Branch: ${AUXILIARY_BRANCH}" + echo "::endgroup::" + + - name: 'Create Pull Request' + id: create-pull-request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.CI_MACHINE_TOKEN }} + branch: ${{ steps.update-pom.outputs.auxiliary-branch }} + commit-message: "📦 Publishing an Empty Starter version [${{ steps.update-pom.outputs.starter-version }}]" + title: 'Update starter.deploy.version to [${{ steps.update-pom.outputs.starter-version }}]' + body: > + This PR was created automatically to update the **starter.deploy.version** in pom.xml to [**${{ steps.update-pom.outputs.starter-version }}**]. + labels: | + empty-starter + automated pr + + send-notification: + needs: [ deploy-artifacts, update-pom ] + runs-on: ubuntu-20.04 + environment: trunk + if: always() && github.event.inputs.dry-run == false steps: + - uses: actions/checkout@v4 + + - name: Compose Message + id: compose-message + run: | + echo "::group::Compose Message" + ARTIFACT_FILENAME="${{ needs.deploy-artifacts.outputs.filename }}" + ARTIFACT_URL="${{ needs.deploy-artifacts.outputs.url }}" + CHANGELOG="${{ github.event.inputs.changelog }}" + PULL_REQUEST_URL="${{ needs.update-pom.outputs.pull-request-url }}" + if [ "$STARTER_TYPE" == "empty" ]; then + PR_ALERT="> :exclamation:*Approvals required*:exclamation: *PR* ${PULL_REQUEST_URL}" + fi + + BASE_MESSAGE=$(cat <<-EOF + > :large_green_circle: *Attention dotters:* a new Starter published! + > This automated script is happy to announce that a new *_${STARTER_TYPE} starter_* :package: \`${ARTIFACT_FILENAME}\` is now available on \`ARTIFACTORY\` :frog:! + > + > :link: ${ARTIFACT_URL} + > *Changelog* + > \`\`\`${CHANGELOG}\`\`\` + > + ${PR_ALERT} + EOF + ) + + MESSAGE="${BASE_MESSAGE}" + + echo "Message: ${MESSAGE}" + echo "message<> $GITHUB_OUTPUT + echo "${MESSAGE}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "::endgroup::" + - name: Slack Notification uses: ./.github/actions/core-cicd/notification/notify-slack with: channel-id: "log-starter" - payload: | - > :large_green_circle: *Attention dotters:* a new Starter published! - > - > This automated script is happy to announce that a new *_${{ env.STARTER_TYPE }}_* :package: `${{ needs.deploy-artifacts.outputs.filename }}` is now available on `ARTIFACTORY` :frog:! - > :link:${{ needs.deploy-artifacts.outputs.url }} - > *Changelog* - > ```${{ github.event.inputs.changelog }}``` + payload: ${{ steps.compose-message.outputs.message }} slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + diff --git a/.github/workflows/cicd_post-workflow-reporting.yml b/.github/workflows/cicd_post-workflow-reporting.yml index c8bb5622c59..d8e8c71340a 100644 --- a/.github/workflows/cicd_post-workflow-reporting.yml +++ b/.github/workflows/cicd_post-workflow-reporting.yml @@ -271,4 +271,11 @@ jobs: channel-id: ${{ vars.SLACK_REPORT_CHANNEL }} payload: ${{ steps.prepare-slack-message.outputs.payload }} json: true - slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + # Check can be removed if we have resolved root cause + # We cannot use a local github action for this as it is run before we checkout the repo + # secrets.GITHUB_TOKEN is not available in composite workflows so it needs to be passed in. + - name: Check API Rate Limit + shell: bash + run: | + curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/rate_limit || true \ No newline at end of file diff --git a/.github/workflows/cicd_post_sync-main-with-master.yml b/.github/workflows/cicd_post_sync-main-with-master.yml index f0ed8396054..564b6ddd56a 100644 --- a/.github/workflows/cicd_post_sync-main-with-master.yml +++ b/.github/workflows/cicd_post_sync-main-with-master.yml @@ -13,16 +13,16 @@ jobs: runs-on: ubuntu-latest steps: - - name: 'Setup git config' - run: | - git config user.name "${{ secrets.CI_MACHINE_USER }}" - git config user.email "dotCMS-Machine-User@dotcms.com" - - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch all history for all branches + - name: 'Setup git config' + run: | + git config user.name "${{ secrets.CI_MACHINE_USER }}" + git config user.email "dotCMS-Machine-User@dotcms.com" + - name: Create or update main branch run: | # Check if 'main' branch exists @@ -35,5 +35,10 @@ jobs: git checkout -b main origin/master fi - # Push the updated main branch - git push origin main --force \ No newline at end of file + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.CI_MACHINE_TOKEN }} + branch: main + force: true + tags: true \ No newline at end of file diff --git a/.github/workflows/cicd_scheduled_notify-seated-prs.yml b/.github/workflows/cicd_scheduled_notify-seated-prs.yml index 61b174cde97..14ae851f360 100644 --- a/.github/workflows/cicd_scheduled_notify-seated-prs.yml +++ b/.github/workflows/cicd_scheduled_notify-seated-prs.yml @@ -3,6 +3,12 @@ on: schedule: - cron: '0 10 * * *' workflow_dispatch: + inputs: + current_user: + description: 'Limit execution of this workflow to the current user' + type: string + required: false + default: U0125JCDSSE env: PR_DAY_THRESHOLD: 3 DRAFT_PR_DAY_THRESHOLD: 5 @@ -20,6 +26,7 @@ jobs: env: GITHUB_CONTEXT: ${{ toJson(github) }} - name: Filter execution + id: filter-execution uses: actions/github-script@v7 with: result-encoding: string @@ -28,11 +35,13 @@ jobs: console.log(new Date()); if (day === 0 || day === 6) { console.log('It\'s (happy) weekend, not sending any notifications'); - process.exit(1); + process.exit(0); } + + core.setOutput('continue', 'true'); - id: fetch-seated-prs name: Fetch Seated PRs - if: success() + if: success() && steps.filter-execution.outputs.continue == 'true' uses: actions/github-script@v7 with: result-encoding: string @@ -146,23 +155,25 @@ jobs: with: result-encoding: string script: | + const member = '${{ matrix.member }}'; const urlMapper = (pr) => `- ${pr.url}`; - const prDayThreshold = ${{ env.PR_DAY_THRESHOLD }}; const draftPrDayThreshold = ${{ env.DRAFT_PR_DAY_THRESHOLD }}; const seatedPrs = ${{ needs.resolve-seated-prs.outputs.seated_prs }} + const mappings = ${{ needs.slack-channel-resolver.outputs.mappings_json }}; + + const foundMapping = mappings.find(mapping => mapping.slack_id === member) + if (!foundMapping) { + core.warning(`Slack user Id [${member}] cannot be found, exiting`); + process.exit(0); + } + const members = ${{ needs.resolve-seated-prs.outputs.members_json }} const channels = ${{ needs.slack-channel-resolver.outputs.channel_ids }} console.log(JSON.stringify(members, null, 2)); console.log(JSON.stringify(channels, null, 2)); - const idx = channels.findIndex(channel => channel === '${{ matrix.member }}'); - if (idx === -1) { - console.log('Could not find channel [${{ matrix.member }}], skipping this'); - process.exit(2); - } - - const login = members[idx]; + const login = foundMapping.github_user; const userPrs = seatedPrs.find(pr => pr.login === login); const prs = userPrs.prs.filter(pr => !pr.draft).map(urlMapper); const draftPrs = userPrs.prs.filter(pr => pr.draft).map(urlMapper); @@ -183,14 +194,17 @@ jobs: core.setOutput('message', message); - name: Notify member - if: success() + if: success() && steps.build-message.outputs.message != '' shell: bash run: | channel=${{ matrix.member }} - - curl -X POST \ - -H "Content-type: application/json" \ - -H "Authorization: Bearer ${{ secrets.SLACK_BOT_TOKEN }}" \ - -d "{ \"channel\":\"${channel}\",\"text\":\"${{ steps.build-message.outputs.message }}\"}" \ - -s \ - https://slack.com/api/chat.postMessage + + echo "Sending notification to ${channel}" + if [[ -x '${{ inputs.current_user }}' || "${channel}" == '${{ inputs.current_user }}' ]]; then + curl -X POST \ + -H "Content-type: application/json" \ + -H "Authorization: Bearer ${{ secrets.SLACK_BOT_TOKEN }}" \ + -d "{ \"channel\":\"${channel}\",\"text\":\"${{ steps.build-message.outputs.message }}\" }" \ + -s \ + https://slack.com/api/chat.postMessage + fi diff --git a/.github/workflows/issue_comp_next-release-label.yml b/.github/workflows/issue_comp_next-release-label.yml index 9ec6276ea49..2501d787503 100644 --- a/.github/workflows/issue_comp_next-release-label.yml +++ b/.github/workflows/issue_comp_next-release-label.yml @@ -28,23 +28,38 @@ jobs: env: GITHUB_CONTEXT: ${{ toJson(github) }} - name: Validate inputs + id: validate-inputs uses: actions/github-script@v7 with: result-encoding: string script: | + let issueNumber; const issue = context.payload.issue; if (!issue && '${{ inputs.issue_number }}'.trim() === '') { - console.log('Issue number is not provided'); + core.warning('Issue number is not provided'); process.exit(0); } - const label = context.payload.label; - if (!label && '${{ inputs.label }}' !== '${{ env.QA_NOT_NEEDED_LABEL }}') { - console.log('Label is not "${{ env.QA_NOT_NEEDED_LABEL }}", exiting'); + let label = context.payload.label; + if (!label) { + if (!'${{ inputs.label }}') { + core.warning('Label is missing, exiting'); + process.exit(0); + } + label = '${{ inputs.label }}'; + } else { + label = label.name; + } + + if (label !== '${{ env.QA_NOT_NEEDED_LABEL }}') { + core.warning('Label is not [${{ env.QA_NOT_NEEDED_LABEL }}], exiting'); process.exit(0); } + + core.setOutput('label', label); - name: Add Next Release label uses: actions/github-script@v7 + if: success() && steps.validate-inputs.outputs.label != '' with: result-encoding: string retries: 3 @@ -67,7 +82,7 @@ jobs: } if (!issue) { - console.log('Issue [${{ inputs.issue_number }}] not found'); + core.warning('Issue [${{ inputs.issue_number }}] not found'); process.exit(0); } } @@ -75,16 +90,16 @@ jobs: console.log(`Issue: ${JSON.stringify(issue, null, 2)}`); const issueNumber = issue.number; - const dropAndLearnText = 'Drop Everything & Learn'; - if (issue.title.includes(dropAndLearnText)) { - console.log(`Issue does have "${dropAndLearnText}" text in title, exiting`); + const dropAndLearnText = 'Drop Everything & Learn'.toLowerCase(); + if (issue.title.toLowerCase().includes(dropAndLearnText)) { + core.warning(`Issue does have "${dropAndLearnText}" text in title, exiting`); process.exit(0); } const typeCicdLabel = 'Type : CI/CD'; const foundLabel = issue.labels.find(label => label.name === typeCicdLabel); if (foundLabel) { - console.log(`Issue does have "${typeCicdLabel}" label , exiting`); + core.warning(`Issue does have "${typeCicdLabel}" label , exiting`); process.exit(0); } @@ -97,3 +112,4 @@ jobs: const updated = await getIssue(issueNumber); console.log(`Labels: ${JSON.stringify(updated.labels, null, 2)}`); + diff --git a/.github/workflows/legacy-release_comp_maven-build-docker-image.yml b/.github/workflows/legacy-release_comp_maven-build-docker-image.yml index cf3e423c5a5..6b8d73aea29 100644 --- a/.github/workflows/legacy-release_comp_maven-build-docker-image.yml +++ b/.github/workflows/legacy-release_comp_maven-build-docker-image.yml @@ -69,8 +69,19 @@ jobs: with: ref: ${{ inputs.ref }} - - name: Cleanup - uses: ./.github/actions/core-cicd/cleanup-runner + + - name: Get SDKMan Version + id: get-sdkman-version + shell: bash + run: | + if [ -f .sdkmanrc ]; then + SDKMAN_JAVA_VERSION=$(awk -F "=" '/^java=/ {print $2}' .sdkmanrc) + echo "using default Java version from .sdkmanrc: ${SDKMAN_JAVA_VERSION}" + echo "SDKMAN_JAVA_VERSION=${SDKMAN_JAVA_VERSION}" >> $GITHUB_OUTPUT + else + echo "No .sdkmanrc file found" + exit 1 + fi - name: Restore Docker Context id: restore-docker-context @@ -90,12 +101,11 @@ jobs: echo "rebuild=${rebuild}" >> $GITHUB_OUTPUT - - name: Setup Java - uses: actions/setup-java@v3 + - name: Prepare Runner + uses: ./.github/actions/core-cicd/prepare-runner with: - java-version: ${{ env.JAVA_VERSION }} - distribution: ${{ env.JAVA_DISTRO }} - if: steps.build-status.outputs.rebuild == 'true' + cleanup-runner: true + require-java: ${{ steps.build-status.outputs.rebuild == 'true' || 'false'}} - name: Build Core run: | @@ -273,6 +283,8 @@ jobs: push: true cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + SDKMAN_JAVA_VERSION=${{ steps.get-sdkman-version.outputs.SDKMAN_JAVA_VERSION }} if: success() - name: Format Tags diff --git a/.github/workflows/utility_slack-channel-resolver.yml b/.github/workflows/utility_slack-channel-resolver.yml index 8ad2110a580..818e12e8a57 100644 --- a/.github/workflows/utility_slack-channel-resolver.yml +++ b/.github/workflows/utility_slack-channel-resolver.yml @@ -29,6 +29,8 @@ on: outputs: channel_ids: value: ${{ jobs.slack-channel-resolver.outputs.channel_ids }} + mappings_json: + value: ${{ jobs.slack-channel-resolver.outputs.mappings_json }} secrets: CI_MACHINE_USER: description: 'CI machine user' @@ -45,6 +47,7 @@ jobs: runs-on: ubuntu-20.04 outputs: channel_ids: ${{ steps.resolve-channels.outputs.channel_ids }} + mappings_json: ${{ steps.resolve-channels.outputs.mappings_json }} steps: - name: Resolve Users id: resolve-users @@ -70,7 +73,8 @@ jobs: githack_core_repo_url=https://${githack_host}/${{ github.repository }} slack_mappings_file=slack-mappings.json slack_mapping_url=${githack_core_repo_url}/${{ inputs.branch }}/.github/data/${slack_mappings_file} - json=$(curl -s ${slack_mapping_url}) + json=$(curl -s ${slack_mapping_url} | jq -c .) + echo "json: ${json}" echo "Looking for [${github_user}]" channel_ids= @@ -115,7 +119,9 @@ jobs: fi fi - [[ -n "${channel_id}" ]] && channel_ids="${channel_ids} ${channel_id}" + if [[ -n "${channel_id}" && "${channel_id}" != 'null' ]]; then + channel_ids="${channel_ids} ${channel_id}" + fi done default_channel_id=${{ inputs.default_channel_id }} @@ -135,5 +141,6 @@ jobs: channel_ids=$(printf '%s\n' "${deduped_channel_ids[@]}" | jq -R . | jq -s . | tr -d '\n' | tr -d ' ') fi + echo "mappings_json=${json}" >> $GITHUB_OUTPUT echo "channel_ids: [${channel_ids}]" echo "channel_ids=${channel_ids}" >> $GITHUB_OUTPUT diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 9d52d1d8a31..7c0ecb3ac7f 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1579,6 +1579,12 @@ 3.5.3 + + com.microsoft.playwright + playwright + 1.46.0 + + org.graalvm.sdk diff --git a/core-web/.husky/pre-commit b/core-web/.husky/pre-commit index 436c029cfb8..06220484f0b 100755 --- a/core-web/.husky/pre-commit +++ b/core-web/.husky/pre-commit @@ -1,5 +1,10 @@ #!/usr/bin/env bash +# Ensure this script runs with bash +if [ -z "$BASH_VERSION" ]; then + exec bash "$0" "$@" +fi + # Color definitions RED='\033[0;31m' GREEN='\033[0;32m' @@ -7,47 +12,59 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +# Function to print colored output +print_color() { + printf "%b%s%b\n" "$1" "$2" "$NC" +} + # This script is used as a pre-commit hook for a Git repository. # It performs operations such as formatting and linting on staged files. # Source husky script . "$(dirname "$0")/_/husky.sh" || { - echo "Failed to source husky.sh" + print_color "$RED" "Failed to source husky.sh" exit 1 } +# Determine the root directory of the git repository +root_dir="$(git rev-parse --show-toplevel)" + # Load nvm and use the version specified in .nvmrc -load_nvm_and_use_node() { - export NVM_DIR="$HOME/.nvm" +setup_nvm() { + # Check if nvm is already installed if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" # This loads nvm - echo "nvm loaded." - - if [ -f ".nvmrc" ]; then - nvm use || { - echo "Failed to switch to Node version specified in .nvmrc" - exit 1 - } - echo "Using Node version from .nvmrc" - else - echo "No .nvmrc file found. Using default Node version." - fi + print_color "$GREEN" "nvm is already installed." else - echo "nvm is not installed or could not be found." - exit 1 + print_color "$YELLOW" "Installing nvm..." + # Use the project's Node.js to download and run the nvm install script + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + + print_color "$GREEN" "nvm installed successfully." + fi + + # Source nvm + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + + # Ensure the correct Node.js version is used + if [ -f "$root_dir/.nvmrc" ]; then + print_color "$BLUE" "Using Node.js version specified in .nvmrc" + nvm install + else + print_color "$YELLOW" "No .nvmrc found. Using default Node.js version." + nvm use default fi } check_sdk_client_affected() { - local YARN_EXEC="${root_dir}/installs/node/yarn/dist/bin/yarn" local affected_projects=$(npx nx show projects --affected) - echo "Affected projects: $affected_projects" + printf "Affected projects: %s\n" "$affected_projects" # Build sdk-client if affected if echo "$affected_projects" | grep -q "sdk-client"; then - echo "Building sdk-client" - if ! $YARN_EXEC nx run sdk-client:build:js; then - echo "Failed to build sdk-client" + printf "Building sdk-client\n" + if ! yarn nx run sdk-client:build:js; then + print_color "$RED" "Failed to build sdk-client" has_errors=true fi fi @@ -58,21 +75,21 @@ check_sdk_client_affected() { # Check if the file exists, then delete it if [ -f "$file_to_remove" ]; then rm "$file_to_remove" - echo "Removed $file_to_remove" + printf "Removed %s\n" "$file_to_remove" else - echo "$file_to_remove does not exist" + printf "%s does not exist\n" "$file_to_remove" fi # Check if the directory exists, then delete it if [ -d "$dir_to_remove" ]; then rm -r "$dir_to_remove" - echo "Removed directory $dir_to_remove" + printf "Removed directory %s\n" "$dir_to_remove" else - echo "$dir_to_remove does not exist" + printf "%s does not exist\n" "$dir_to_remove" fi if ! git add "${root_dir}/dotCMS/src/main/webapp/html/js/editor-js/sdk-editor.js"; then - echo "Failed to stage computed sdk-client.js" + print_color "$RED" "Failed to stage computed sdk-client.js" exit 1 fi } @@ -81,20 +98,20 @@ check_sdkman() { SDKMAN_INIT="$HOME/.sdkman/bin/sdkman-init.sh" # Check if sdkman-init.sh exists and is readable - if [[ -s "$SDKMAN_INIT" ]]; then + if [ -s "$SDKMAN_INIT" ]; then source "$SDKMAN_INIT" # Source sdkman to make sdk command available - echo "${GREEN}SDKMAN! sourced from $SDKMAN_INIT${NC}" + print_color "$GREEN" "SDKMAN! sourced from $SDKMAN_INIT" # Optionally check if sdk command is available if command -v sdk >/dev/null 2>&1; then - echo "${GREEN}SDKMAN! is installed and functional.${NC}" + print_color "$GREEN" "SDKMAN! is installed and functional." else - echo "${RED}SDKMAN! command is not available. Please check the installation.${NC}" + print_color "$RED" "SDKMAN! command is not available. Please check the installation." exit 1 fi else - echo "${RED}SDKMAN! not found at $SDKMAN_INIT. Please install SDKMAN! first.${NC}" - echo "${RED}You can install this and other required utilities from${NC} https://github.com/dotCMS/dotcms-utilities" + print_color "$RED" "SDKMAN! not found at $SDKMAN_INIT. Please install SDKMAN! first." + print_color "$RED" "You can install this and other required utilities from https://github.com/dotCMS/dotcms-utilities" exit 1 fi } @@ -107,35 +124,22 @@ perform_frontend_fixes() { local add_to_index="$2" local file - local NODE_EXEC="${root_dir}/installs/node/node" - local YARN_EXEC="${root_dir}/installs/node/yarn/dist/bin/yarn" - - # Check if Yarn and Node executables are present - if [[ ! -x "$YARN_EXEC" || ! -x "$NODE_EXEC" ]]; then - echo "Yarn or Node executables are missing." - exit 1 - fi - - echo "Yarn version: $($YARN_EXEC --version 2>/dev/null)" - echo "Node version: $($NODE_EXEC --version)" - $YARN_EXEC config set registry https://dotcms-npm.b-cdn.net - # Check if yarn.lock is present - if ! $YARN_EXEC install; then - echo "Failed to install dependencies with yarn install" - echo "Please run 'yarn install' to update the lockfile and make sure you commit this with any package.json changes." + if ! yarn install; then + print_color "$RED" "Failed to install dependencies with yarn install" + print_color "$YELLOW" "Please run 'yarn install' to update the lockfile and make sure you commit this with any package.json changes." has_errors=true return 1 else - echo "Completed yarn install adding yarn.lock if it was modified" + printf "Completed yarn install adding yarn.lock if it was modified\n" git add "${root_dir}/core-web/yarn.lock" fi - if ! $YARN_EXEC nx affected -t lint --exclude='tag:skip:lint' --fix=true; then + if ! yarn nx affected -t lint --exclude='tag:skip:lint' --fix=true; then has_errors=true fi - if ! $YARN_EXEC nx format:write; then + if ! yarn nx format:write; then has_errors=true fi @@ -156,27 +160,27 @@ perform_frontend_fixes() { done if [ ${#unmatched_files[@]} -ne 0 ]; then - echo "===================================" - echo "Warning: The following unrelated files in the affected modules should be linted and/or formatted" - echo "" + printf "===================================\n" + print_color "$YELLOW" "Warning: The following unrelated files in the affected modules should be linted and/or formatted" + printf "\n" for file in "${unmatched_files[@]}"; do - echo " ${file}" + printf " %s\n" "${file}" git restore "${file}" done - echo "" - echo "You can fix these files by running the following commands:" - echo "nx affected -t lint --exclude='tag:skip:lint' --fix=true" - echo "nx format:write;" + printf "\n" + print_color "$YELLOW" "You can fix these files by running the following commands:" + printf "nx affected -t lint --exclude='tag:skip:lint' --fix=true\n" + printf "nx format:write;\n" fi fi } restore_untracked_files() { if [ "$backup_untracked" = true ] && [ -n "${untracked_files}" ] && [ "$(ls -A "${temp_dir}")" ]; then - echo "Restoring untracked files..." + printf "Restoring untracked files...\n" # Copy each file back from the temporary directory while maintaining the directory structure find "${temp_dir}" -type f -exec sh -c ' - for file; do + for file do temp_dir='"${temp_dir}"' root_dir='"${root_dir}"' rel_path="${file#${temp_dir}/}" # Extract the relative path by removing the temp directory prefix @@ -186,7 +190,6 @@ restore_untracked_files() { cp "${file}" "${full_dest_path}" # Copy the file to the destination directory done ' sh {} + - fi # Clean up temporary directory @@ -204,27 +207,51 @@ root_dir="$(git rev-parse --show-toplevel)" # from core-web/pom.xml and .nvmrc files are updated cd "${root_dir}" || exit 1 +# Need to follow steps to ensure versions are available +# 1. SDKMAN to get right version of java before maven can be called. +# 2. Run base validate on maven, will ensure that local version of node and yarn are installed to ${root_dir}/installs/node and ${root_dir}/installs/node/yarn/dist/bin:$PATH +#. This also updates the local .nvmrc file +# 3 Ensure that developers have the correct version of node and yarn installed with nvm + check_sdkman -echo "Setting up Java" +printf "Setting up Java\n" sdk env install -echo "Initializing Maven, Node, and Yarn versions" +printf "Initializing Maven, Node, and Yarn versions\n" +# Output maven and java versions +./mvnw --version + +# Run a base validate on maven to ensure that the correct version of node and yarn are installed if ! ./mvnw validate -pl :dotcms-core --am -q; then - echo "Failed to run './mvnw validate -pl :dotcms-core --am'" - echo "Please run the following command to see the detailed output:" - echo "./mvnw validate -pl :dotcms-core --am" + print_color "$RED" "Failed to run './mvnw validate -pl :dotcms-core --am'" + print_color "$YELLOW" "Please run the following command to see the detailed output:" + printf "./mvnw validate -pl :dotcms-core --am\n" exit 1 else - echo "Completed Maven init" + printf "Completed Maven init\n" +fi + +# Update PATH to include project-specific Node.js and Yarn +export PATH="${root_dir}/installs/node:${root_dir}/installs/node/yarn/dist/bin:$PATH" + +# Verify that node and yarn are accessible +if ! command -v node > /dev/null 2>&1 || ! command -v yarn > /dev/null 2>&1; then + print_color "$RED" "Error: Node.js or Yarn not found in the project directory." + print_color "$YELLOW" "Please ensure they are installed in ${root_dir}/installs/node" + exit 1 fi +# Not needed for this script as we are referring directly in the path to the binaries of maven and yarn maven downloaded +# We want to make sure the developer has the correct version using nvm +setup_nvm + +# Log the versions being used +printf "Using Node version: %s\n" "$(node --version)" +printf "Using Yarn version: %s\n" "$(yarn --version)" core_web_dir="${root_dir}/core-web" cd "${core_web_dir}" || exit 1 -# Load nvm and use the node version specified in .nvmrc -load_nvm_and_use_node - staged_files=$(git diff --cached --name-only) modified_files=$(git diff --name-only) @@ -235,26 +262,25 @@ if [ -n "${staged_files}" ]; then core_web_files_staged=$(echo "$staged_files" | grep -E '^core-web/' || true ) # Determine if untracked files should be backed up - backup_untracked=true # Default to false if not set + backup_untracked=true # Default to true if not set fi -echo ${core_web_files_staged} +printf "%s\n" "${core_web_files_staged}" # Only create a temporary directory if there are untracked files and backup_untracked is true if [ "$backup_untracked" = true ] && [ -n "${untracked_files}" ]; then temp_dir=$(mktemp -d) if [ ! -d "${temp_dir}" ]; then - echo "Failed to create temporary directory." + print_color "$RED" "Failed to create temporary directory." exit 1 fi - echo "Created temporary directory ${temp_dir}" - + printf "Created temporary directory %s\n" "${temp_dir}" for file in $untracked_files; do if echo "${staged_files}" | grep -q "^${file}$"; then mkdir -p "${temp_dir}/$(dirname "${file}")" # Ensure the directory structure exists in the temp directory cp "${root_dir}/${file}" "${temp_dir}/${file}" # Copy the file to the temp directory, preserving the directory structure - echo "Backing up ${file} to ${temp_dir}/${file}" + printf "Backing up %s to %s\n" "${file}" "${temp_dir}/${file}" # Restore the original file state in the repo, removing unstaged changes git restore "${root_dir}/${file}" # Using relative path relative to current directory fi @@ -269,18 +295,20 @@ if [ "$backup_untracked" = true ] && [ -n "${untracked_files}" ]; then fi done - echo "Backed up workspace to ${temp_dir}" + printf "Backed up workspace to %s\n" "${temp_dir}" fi + # Run fixes on staged files if [ -n "$core_web_files_staged" ]; then perform_frontend_fixes "${core_web_files_staged}" true errors=$? # Capture the return value from perform_frontend_fixes fi + # Restore untracked files if necessary restore_untracked_files ## Running fixes on untracked files -core_web_files_untracked=$(echo "untracked_files" | grep -E '^core-web/' || true ) +core_web_files_untracked=$(echo "$untracked_files" | grep -E '^core-web/' || true ) if [ -n "$core_web_files_untracked" ]; then perform_frontend_fixes "${core_web_files_untracked}" false fi @@ -293,9 +321,9 @@ cd "${original_pwd}" || exit 1 # Exit if the directory does not exist # Final check before exiting if [ "$has_errors" = true ]; then - echo "Checks failed. Force commit with --no-verify option if bypass required." + print_color "$RED" "Checks failed. Force commit with --no-verify option if bypass required." exit 1 # Change the exit code to reflect that an error occurred else - echo "Commit checks completed successfully." + print_color "$GREEN" "Commit checks completed successfully." exit 0 # No errors, exit normally fi diff --git a/core-web/apps/dotcms-ui/proxy-dev.conf.mjs b/core-web/apps/dotcms-ui/proxy-dev.conf.mjs index 81ea26bc64f..bda865f0d93 100644 --- a/core-web/apps/dotcms-ui/proxy-dev.conf.mjs +++ b/core-web/apps/dotcms-ui/proxy-dev.conf.mjs @@ -14,13 +14,18 @@ export default [ '/dotcms-block-editor', '/dotcms-binary-field-builder', '/categoriesServlet', - '/JSONTags' + '/JSONTags', + '/api/vtl', + '/tinymce' ], target: 'http://localhost:8080', secure: false, logLevel: 'debug', pathRewrite: { - '^/assets': '/dotAdmin/assets' + '^/assets/manifest.json': '/dotAdmin/assets/manifest.json', + '^/assets/monaco-editor/min': '/dotAdmin/assets/monaco-editor/min', + '^/assets': '/dotAdmin', + '^/tinymce': '/dotAdmin/tinymce' } } ]; diff --git a/core-web/apps/dotcms-ui/proxy.conf.json b/core-web/apps/dotcms-ui/proxy.conf.json deleted file mode 100644 index 6b5569fc07a..00000000000 --- a/core-web/apps/dotcms-ui/proxy.conf.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "/api": { - "target": "http://localhost:8080", - "secure": false - }, - "/c/portal": { - "target": "http://localhost:8080", - "secure": false - }, - "/html": { - "target": "http://localhost:8080", - "secure": false - }, - "/dwr": { - "target": "http://localhost:8080", - "secure": false - }, - "/dA": { - "target": "https://demo.dotcms.com", - "secure": false - }, - "/dotcms-webcomponents": { - "target": "http://localhost:8080", - "secure": false - }, - "/DotAjaxDirector": { - "target": "http://localhost:8080", - "secure": false - }, - "/contentAsset": { - "target": "http://localhost:8080", - "secure": false - } -} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/main/dot-edit-page-nav/dot-edit-page-nav.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/main/dot-edit-page-nav/dot-edit-page-nav.component.scss index afa30140a04..79e92f49e12 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/main/dot-edit-page-nav/dot-edit-page-nav.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-edit-page/main/dot-edit-page-nav/dot-edit-page-nav.component.scss @@ -54,7 +54,7 @@ $nav-size: 80px; } & > .fa { - font-size: 1.25em; + font-size: $font-size-sm; margin-bottom: 8px; } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts index 9a71238354a..fbd993a6cbf 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.spec.ts @@ -477,7 +477,7 @@ describe('DotContentTypesPortletComponent', () => { describe('filterBy', () => { beforeEach(() => { - router.queryParams = of({ + router.data = of({ filterBy: 'FORM' }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts index 15159200c7e..173bf70295e 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/shared/dot-content-types-listing/dot-content-types.component.ts @@ -91,7 +91,7 @@ export class DotContentTypesPortletComponent implements OnInit, OnDestroy { map((environments: DotEnvironment[]) => !!environments.length), take(1) ), - this.route.queryParams.pipe(pluck('filterBy'), take(1)) + this.route.data.pipe(pluck('filterBy'), take(1)) ).subscribe(([contentTypes, isEnterprise, environments, filterBy]) => { const baseTypes: StructureTypeView[] = contentTypes; diff --git a/core-web/libs/block-editor/src/lib/shared/components/suggestion-list/components/suggestions-list-item/suggestions-list-item.component.ts b/core-web/libs/block-editor/src/lib/shared/components/suggestion-list/components/suggestions-list-item/suggestions-list-item.component.ts index 4e333eb9fb0..5615c8bc350 100644 --- a/core-web/libs/block-editor/src/lib/shared/components/suggestion-list/components/suggestions-list-item/suggestions-list-item.component.ts +++ b/core-web/libs/block-editor/src/lib/shared/components/suggestion-list/components/suggestions-list-item/suggestions-list-item.component.ts @@ -34,7 +34,7 @@ export class SuggestionsListItemComponent implements FocusableOption, OnInit { } ngOnInit() { - this.icon = this.icon = typeof this.url === 'string' && !(this.url.split('/').length > 1); + this.icon = typeof this.url === 'string' && !(this.url.split('/').length > 1); } getLabel(): string { diff --git a/core-web/libs/block-editor/src/lib/shared/components/suggestions/suggestions.component.html b/core-web/libs/block-editor/src/lib/shared/components/suggestions/suggestions.component.html index 0611e2bb4cb..566a130c909 100644 --- a/core-web/libs/block-editor/src/lib/shared/components/suggestions/suggestions.component.html +++ b/core-web/libs/block-editor/src/lib/shared/components/suggestions/suggestions.component.html @@ -4,7 +4,7 @@

{{ title }}

} - @for (item of items; track $index) { + @for (item of items; track item) { @if (item.id !== 'divider') { {{ title }} } @else {
} - -
-
}
diff --git a/core-web/libs/dot-rules/src/lib/styles/rule-engine.scss b/core-web/libs/dot-rules/src/lib/styles/rule-engine.scss index 7255ffc367f..0a61153ce13 100644 --- a/core-web/libs/dot-rules/src/lib/styles/rule-engine.scss +++ b/core-web/libs/dot-rules/src/lib/styles/rule-engine.scss @@ -213,7 +213,7 @@ $finder-s-input: ".ui.input input"; .cw-filter-links { padding: $spacing-1; - font-size: 0.8em; + font-size: $font-size-sm; color: $foreground-disabled; span:first-child { diff --git a/core-web/libs/dotcms-scss/jsp/css/document.css b/core-web/libs/dotcms-scss/jsp/css/document.css index 6724ac1ccef..a4144292c45 100644 --- a/core-web/libs/dotcms-scss/jsp/css/document.css +++ b/core-web/libs/dotcms-scss/jsp/css/document.css @@ -1,3 +1,9 @@ +/* + ========================== WARNING ========================== + Don't use this in your components, use $font-size-md instead, + this is only for the root element + ============================================================= +*/ body, div, dl, @@ -55,7 +61,7 @@ acronym { } h1 { - font-size: 1.5em; + font-size: 1.25rem; font-weight: normal; line-height: 1em; margin-top: 1em; @@ -63,7 +69,7 @@ h1 { } h2 { - font-size: 1.1667em; + font-size: 1rem; font-weight: bold; line-height: 1.286em; margin-top: 1.929em; @@ -74,7 +80,7 @@ h3, h4, h5, h6 { - font-size: 1em; + font-size: 0.875rem; font-weight: bold; line-height: 1.5em; margin-top: 1.5em; @@ -82,14 +88,14 @@ h6 { } p { - font-size: 1em; + font-size: 0.875rem; margin-top: 1.5em; margin-bottom: 1.5em; line-height: 1.5em; } blockquote { - font-size: 0.916em; + font-size: 0.75rem; margin-top: 3.272em; margin-bottom: 3.272em; line-height: 1.636em; @@ -100,7 +106,7 @@ blockquote { ol li, ul li { - font-size: 1em; + font-size: 0.875rem; line-height: 1.5em; margin: 0; } diff --git a/core-web/libs/dotcms-scss/jsp/css/dotcms.css b/core-web/libs/dotcms-scss/jsp/css/dotcms.css index f3fcd834316..4879c7e561a 100644 --- a/core-web/libs/dotcms-scss/jsp/css/dotcms.css +++ b/core-web/libs/dotcms-scss/jsp/css/dotcms.css @@ -2881,6 +2881,9 @@ --color-palette-primary-op-70: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.7); --color-palette-primary-op-80: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.8); --color-palette-primary-op-90: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.9); + --color-palette-primary-shade: var(--color-palette-primary-600); + --color-palette-primary: var(--color-palette-primary-500); + --color-palette-primary-tint: var(--color-palette-primary-200); --color-palette-secondary-100: hsl(var(--color-secondary-h) var(--color-secondary-s) 98%); --color-palette-secondary-200: hsl(var(--color-secondary-h) var(--color-secondary-s) 94%); --color-palette-secondary-300: hsl(var(--color-secondary-h) var(--color-secondary-s) 84%); @@ -2944,6 +2947,9 @@ 60%, 0.9 ); + --color-palette-secondary-shade: var(--color-palette-secondary-600); + --color-palette-secondary: var(--color-palette-secondary-500); + --color-palette-secondary-tint: var(--color-palette-secondary-200); /* COLOR BLACK OPACITY */ --color-palette-black-op-10: hsla( var(--color-black-h), @@ -3160,8 +3166,6 @@ .unlockIcon, .unarchiveIcon, .trashIcon, -.toggleOpenIcon, -.toggleCloseIcon, .thumbnailViewIcon, .textFieldIcon, .tagIcon, @@ -3551,7 +3555,7 @@ .dijitTextBox, .dijitToggleButton { border-radius: 0.375rem; - height: 2.5rem; + height: 2.125rem; } .dijitButton, @@ -3768,7 +3772,7 @@ color: #14151a; font-family: Assistant, 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif; padding: 0.5rem; - font-size: 1rem; + font-size: 0.875rem; min-height: 9.375rem; resize: vertical; } @@ -3928,6 +3932,10 @@ .dijitToggleButton .dijitButtonNode { padding: 0 1rem; margin: none; + height: 100%; + display: flex; + align-items: center; + justify-content: center; } .dijitButton .dijitButtonNode .dijitIcon, .dijitToggleButton .dijitButtonNode .dijitIcon { @@ -3958,7 +3966,7 @@ } .dijitButtonNode { border: none; - font-size: 1rem; + font-size: 0.875rem; } .dijitButtonHover { background-color: var(--color-palette-primary-100); @@ -3973,11 +3981,19 @@ box-shadow: 0; outline: 2.8px solid var(--color-palette-primary-op-20); } -.dijitButtonDisabled { +.dijitButtonDisabledFocused { + background-color: #f3f3f4; + border: solid 1.5px #ebecef; + color: #afb3c0; + box-shadow: 0; + outline: none; +} +.dijitButtonDisabled.dijitDisabled { background-color: #f3f3f4; border: solid 1.5px #ebecef; color: #afb3c0; box-shadow: 0; + outline: none; } .dijitDropDownButton { background-color: #ffffff; @@ -4247,7 +4263,7 @@ color: #ffffff; line-height: 2.5rem; margin: 0; - font-size: 1.25rem; + font-size: 1rem; } .dijitDropDownActionButton .dijitButtonNode { height: 2.5rem; @@ -4345,26 +4361,39 @@ } .dijitCheckBox { - border-radius: 2px; - border: solid 2px #14151a; + border-radius: 4px; + border: solid 2px #afb3c0; background-color: #ffffff; width: 24px; + min-width: 24px; height: 24px; + cursor: pointer; +} +.dijitCheckBox .dijitCheckBoxInput { + width: 24px; + height: 24px; + cursor: pointer; } .dijitCheckBoxChecked { - border: solid 1px var(--color-palette-primary-500); - background: var(--color-palette-primary-500) - url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxMHB4IiBoZWlnaHQ9IjhweCIgdmlld0JveD0iMCAwIDEwIDgiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+ICAgICAgICA8dGl0bGU+U2hhcGU8L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iU3ltYm9scyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICAgICAgICA8ZyBpZD0iY2hlY2tib3gtYWN0aXZlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNC4wMDAwMDAsIC01LjAwMDAwMCkiIGZpbGw9IiNGRkZGRkYiPiAgICAgICAgICAgIDxwb2x5Z29uIGlkPSJTaGFwZSIgcG9pbnRzPSI3LjMxMDcyMDMyIDExLjQyMjQ0NDkgNC45ODYxOTU2MyA5LjA5NzkyMDE5IDQuMTk0NjMwODcgOS44ODM5MTA1NSA3LjMxMDcyMDMyIDEzIDE0IDYuMzEwNzIwMzIgMTMuMjE0MDA5NiA1LjUyNDcyOTk2Ij48L3BvbHlnb24+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4=') + border: solid 2px var(--color-palette-primary-500); + background: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxMS4zMXB4IiB2aWV3Qm94PSIwIDAgMTAgOCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4gICAgICAgIDx0aXRsZT5TaGFwZTwvdGl0bGU+ICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiAgICA8ZGVmcz48L2RlZnM+ICAgIDxnIGlkPSJTeW1ib2xzIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4gICAgICAgIDxnIGlkPSJjaGVja2JveC1hY3RpdmUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC00LjAwMDAwMCwgLTUuMDAwMDAwKSIgZmlsbD0iIzAwMDAwMCI+ICAgICAgICAgICAgPHBvbHlnb24gaWQ9IlNoYXBlIiBwb2ludHM9IjcuMzEwNzIwMzIgMTEuNDIyNDQ0OSA0Ljk4NjE5NTYzIDkuMDk3OTIwMTkgNC4xOTQ2MzA4NyA5Ljg4MzkxMDU1IDcuMzEwNzIwMzIgMTMgMTQgNi4zMTA3MjAzMiAxMy4yMTQwMDk2IDUuNTI0NzI5OTYiPjwvcG9seWdvbj4gICAgICAgIDwvZz4gICAgPC9nPjwvc3ZnPg==') center center no-repeat; + background-color: #ffffff; +} +.dijitCheckBoxHover:not(.dijitCheckBoxDisabled, .dijitCheckBoxCheckedDisabled) { + border-color: var(--color-palette-primary-400); +} +.dijitCheckBoxFocused:not(.dijitCheckBoxDisabled, .dijitCheckBoxCheckedDisabled) { + outline: 1.5px solid var(--color-palette-primary-op-20); } .dijitCheckBoxDisabled { - border: solid 2px transparent; - background: #afb3c0; + border: solid 2px #d1d4db; + background: #f3f3f4; } .dijitCheckBoxCheckedDisabled { - border: solid 2px transparent; - background: #afb3c0 - url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxMHB4IiBoZWlnaHQ9IjhweCIgdmlld0JveD0iMCAwIDEwIDgiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+ICAgICAgICA8dGl0bGU+U2hhcGU8L3RpdGxlPiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4gICAgPGRlZnM+PC9kZWZzPiAgICA8ZyBpZD0iU3ltYm9scyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICAgICAgICA8ZyBpZD0iY2hlY2tib3gtYWN0aXZlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNC4wMDAwMDAsIC01LjAwMDAwMCkiIGZpbGw9IiNGRkZGRkYiPiAgICAgICAgICAgIDxwb2x5Z29uIGlkPSJTaGFwZSIgcG9pbnRzPSI3LjMxMDcyMDMyIDExLjQyMjQ0NDkgNC45ODYxOTU2MyA5LjA5NzkyMDE5IDQuMTk0NjMwODcgOS44ODM5MTA1NSA3LjMxMDcyMDMyIDEzIDE0IDYuMzEwNzIwMzIgMTMuMjE0MDA5NiA1LjUyNDcyOTk2Ij48L3BvbHlnb24+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4=') + border: solid 2px #d1d4db; + background: #f3f3f4 + url('data:image/svg+xml;base64,PCEtLSBSZXBsYWNlIHRoZSBjb250ZW50cyBvZiB0aGlzIGVkaXRvciB3aXRoIHlvdXIgU1ZHIGNvZGUgLS0+Cgo8c3ZnIHdpZHRoPSIxNnB4IiBoZWlnaHQ9IjExLjMxcHgiIHZpZXdCb3g9IjAgMCAxMCA4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPiAgICAgICAgPHRpdGxlPlNoYXBlPC90aXRsZT4gICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+ICAgIDxkZWZzPjwvZGVmcz4gICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgICAgICAgPGcgaWQ9ImNoZWNrYm94LWFjdGl2ZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTQuMDAwMDAwLCAtNS4wMDAwMDApIiBmaWxsPSIjZDFkNGRiIj4gICAgICAgICAgICA8cG9seWdvbiBpZD0iU2hhcGUiIHBvaW50cz0iNy4zMTA3MjAzMiAxMS40MjI0NDQ5IDQuOTg2MTk1NjMgOS4wOTc5MjAxOSA0LjE5NDYzMDg3IDkuODgzOTEwNTUgNy4zMTA3MjAzMiAxMyAxNCA2LjMxMDcyMDMyIDEzLjIxNDAwOTYgNS41MjQ3Mjk5NiI+PC9wb2x5Z29uPiAgICAgICAgPC9nPiAgICA8L2c+PC9zdmc+') center center no-repeat; } @@ -4374,6 +4403,12 @@ border: solid 2px #afb3c0; height: 24px; width: 24px; + cursor: pointer; +} +.dijitRadio .dijitCheckBoxInput { + width: 24px; + height: 24px; + cursor: pointer; } .dijitRadioChecked { border: solid 2px var(--color-palette-primary-500); @@ -4391,20 +4426,20 @@ top: 50%; width: 14px; } -.dijitRadioHover { +.dijitRadioHover:not(.dijitRadioDisabled) { border-color: var(--color-palette-primary-400); cursor: pointer; } -.dijitRadioFocused { +.dijitRadioFocused:not(.dijitRadioDisabled) { outline: 1.5px solid var(--color-palette-primary-op-20); } .dijitRadioDisabled { - background-color: transparent; + background-color: #f3f3f4; border: solid 2px #d1d4db; position: relative; } .dijitRadioDisabled:before { - background-color: #afb3c0; + background-color: #d1d4db; } #select-arrow, @@ -4828,14 +4863,10 @@ .dijitTitlePane { border-radius: 1px; - box-shadow: - 0 1px 3px var(--color-palette-black-op-10), - 0 1px 2px var(--color-palette-black-op-20); + box-shadow: 0px 2px 8px 0px hsla(230, 13%, 9%, 0.02); } .dijitTitlePaneHover { - box-shadow: - 0 3px 6px var(--color-palette-black-op-10), - 0 3px 6px var(--color-palette-black-op-20); + box-shadow: 0px 4px 8px 0px hsla(230, 13%, 9%, 0.06); } .dijitTitlePaneHover .dijitTitlePaneContentOuter { border: none; @@ -4891,9 +4922,7 @@ background-color: #ffffff; border-radius: 0px; border: none; - box-shadow: - 0 3px 6px var(--color-palette-black-op-10), - 0 3px 6px var(--color-palette-black-op-20); + box-shadow: 0px 4px 8px 0px hsla(230, 13%, 9%, 0.06); } .dijitDialogCloseIcon { width: 17px; @@ -4947,9 +4976,7 @@ color: #14151a; border: 0 none; border-radius: 0.375rem; - box-shadow: - 0px 0px 4px rgba(20, 21, 26, 0.04), - 0px 8px 16px rgba(20, 21, 26, 0.08); + box-shadow: 0px 10px 18px 0px hsla(230, 13%, 9%, 0.1); padding: 0.5rem; margin-top: 0.5rem; } @@ -5528,9 +5555,7 @@ color: #14151a; border: 0 none; border-radius: 0.375rem; - box-shadow: - 0px 0px 4px rgba(20, 21, 26, 0.04), - 0px 8px 16px rgba(20, 21, 26, 0.08); + box-shadow: 0px 10px 18px 0px hsla(230, 13%, 9%, 0.1); padding: 0.5rem; margin-top: 0.5rem; } @@ -5666,7 +5691,8 @@ .dojoxGrid .dojoxGridRow .dojoxGridRowTable .dojoxGridCell, .dojoxGrid .dojoxGridHeader .dojoxGridRowTable .dojoxGridCell { font-family: Assistant, 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif; - padding: 10px 0.5rem; + padding: 0.1875rem 0.5rem; + height: 2.5rem; } .dojoxGrid .dojoxGridPaginator { background-color: #d1d4db; @@ -5766,7 +5792,7 @@ h3, h4, h5, h6 { - font-size: 100%; + font-size: 0.875rem; font-weight: normal; } @@ -6254,32 +6280,33 @@ body { } h1 { - font-size: 174%; + font-size: 1.5rem; } h2 { - font-size: 138.5%; + font-size: 1.25rem; line-height: 115%; margin: 0 0 0.2em 0; font-weight: normal; } h3 { - font-size: 100%; + font-size: 0.875rem; margin: 0 0 0.2em 0; font-weight: bold; } h4 { + font-size: 0.875rem; font-weight: bold; } h5 { - font-size: 77%; + font-size: 0.75rem; } h6 { - font-size: 77%; + font-size: 0.75rem; font-style: italic; } @@ -6339,7 +6366,7 @@ ul li { } .inputCaption { - font-size: 85%; + font-size: 0.75rem; color: #888; font-style: italic; } @@ -6365,12 +6392,12 @@ kbd, samp, tt { font-family: monospace; - *font-size: 108%; + *font-size: 1rem; line-height: 99%; } sup { - font-size: 60%; + font-size: 0.625rem; } abbr { @@ -6478,7 +6505,7 @@ select[multiple]:hover { position: absolute; top: 4px; right: 30px; - font-size: 85%; + font-size: 0.75rem; color: #ddd; } @@ -6572,7 +6599,7 @@ select[multiple]:hover { right: 30px; top: 34px; width: 225px; - font-size: 85%; + font-size: 0.75rem; border: 1px solid #d1d4db; border-top: 0; background: #fff; @@ -6584,7 +6611,7 @@ select[multiple]:hover { .account-flyout h3 { margin: 20px 0 0 0; - font-size: 12px; + font-size: 0.625rem; font-weight: bold; padding: 0 15px; } @@ -6625,7 +6652,7 @@ select[multiple]:hover { } .copyright { - font-size: 10px; + font-size: 0.625rem; color: #555; text-align: center; } @@ -6664,7 +6691,7 @@ select[multiple]:hover { opacity: 0.7; color: #ddd; line-height: 14px; - font-size: 11px; + font-size: 0.625rem; box-shadow: 0px 0px 5px var(--color-palette-black-op-50); border-top-left-radius: 8px; border-top-right-radius: 8px; @@ -6681,7 +6708,7 @@ select[multiple]:hover { .changeHost { cursor: pointer; float: right; - font-size: 85%; + font-size: 0.75rem; line-height: 15px; margin: 6px 10px 0 0; padding: 0; @@ -6717,6 +6744,7 @@ select[multiple]:hover { .listingTable tr, .dojoxUploaderFileListTable tr { transition: background-color 0.1s; + height: 2.4375rem; } #listing-table tr .selected, .listingTable tr .selected, @@ -6729,9 +6757,10 @@ select[multiple]:hover { #listing-table td, .listingTable td, .dojoxUploaderFileListTable td { - padding: 10px 0.5rem; border-bottom: 1px solid #f3f3f4; vertical-align: middle; + height: 2.5rem !important; + padding: 0.5rem; } #listing-table th .listingThumbDiv, .listingTable th .listingThumbDiv, @@ -6739,8 +6768,8 @@ select[multiple]:hover { #listing-table td .listingThumbDiv, .listingTable td .listingThumbDiv, .dojoxUploaderFileListTable td .listingThumbDiv { - height: 60px; - width: 80px; + height: 2.9375rem; + width: 4.5rem; position: relative; border-radius: 0.25rem; overflow: hidden; @@ -6906,7 +6935,7 @@ tr.active { .excelDownload { text-align: right; padding: 5px 10px; - font-size: 85%; + font-size: 0.75rem; } .excelDownload a { @@ -6917,7 +6946,7 @@ tr.active { .lockedAgo { display: block; color: #999; - font-size: 11px; + font-size: 0.625rem; font-style: italic; line-height: 14px; } @@ -6947,7 +6976,7 @@ tr.active { .contentHint { display: block; color: #999; - font-size: 11px; + font-size: 0.625rem; font-weight: normal; line-height: 14px; white-space: normal; @@ -7095,7 +7124,7 @@ tr.active { } .tagsBox a { - font-size: 93%; + font-size: 0.875rem; color: #999; } @@ -7103,13 +7132,13 @@ tr.active { display: block; color: #999; line-height: 140%; - font-size: 80%; + font-size: 0.75rem; margin: 3px 0 0 5px; } .suggestHeading { padding: 7px 10px; - font-size: 12px; + font-size: 0.625rem; font-weight: bold; color: #555; background: url('/html/images/skin/skin-sprite.png') repeat-x scroll 0 -325px transparent; @@ -7818,7 +7847,7 @@ table.sTypeTable.sTypeItem { } .siteOverview span { - font-size: 300%; + font-size: 48px; display: block; padding: 8px; } @@ -7840,7 +7869,7 @@ table.dojoxLegendNode td { #pieChartLegend td.dojoxLegendText { text-align: left; vertical-align: top; - font-size: 85%; + font-size: 0.75rem; line-height: 131%; } @@ -7865,7 +7894,7 @@ table.dojoxLegendNode td { border-radius: 10px; color: #fff; text-align: center; - font-size: 131%; + font-size: 1.25rem; } .noPie { @@ -8048,7 +8077,7 @@ table.dojoxLegendNode td { } .dojoxColorPickerOptional input { - font-size: 11px; + font-size: 0.625rem; border: 1px solid #a7a7a7; width: 25px; padding: 1px 3px 1px 3px; @@ -8057,7 +8086,7 @@ table.dojoxLegendNode td { .dojoxColorPickerHex input { width: 55px; - font-size: 11px; + font-size: 0.625rem; } /* Image Picker */ @@ -8133,7 +8162,7 @@ div#_dotHelpMenu { background: #fff; color: #5f5f5f; display: block; - font-size: 85%; + font-size: 0.75rem; margin: 0; padding: 3px 10px; vertical-align: middle; @@ -8181,7 +8210,7 @@ div#_dotHelpMenu { } .navbar .navMenu-title { color: #404040; - font-size: 93%; + font-size: 0.875rem; font-weight: 700; line-height: 14px; margin: 0; @@ -8190,7 +8219,7 @@ div#_dotHelpMenu { } .navbar .navMenu-subtitle { color: #5f5f5f; - font-size: 77%; + font-size: 0.625rem; margin: 0; overflow: hidden; padding: 0; @@ -8215,13 +8244,12 @@ div#_dotHelpMenu { .subNavCrumbTrail li:after { display: inline-block; font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; content: '\f054'; color: #afb3c0; - font-size: 9px; + font-size: 0.625rem; position: absolute; right: 5px; top: 5px; @@ -9273,7 +9301,7 @@ Styles for commons fields along the backend border-radius: 0.125rem; border: solid 1.5px #d1d4db; display: inline-block; - font-size: 12px; + font-size: 0.625rem; margin: 2px 5px 2px 0px; padding: 0.25rem 0.5rem 0.25rem 0.25rem; position: relative; @@ -9293,7 +9321,7 @@ Styles for commons fields along the backend .lineDividerTitle { color: #6c7389; - font-size: 1.5rem; + font-size: 1.25rem; border-bottom: 1px solid #d1d4db; padding-bottom: 1rem; margin: 3rem 0 1.5rem; @@ -9411,23 +9439,31 @@ Styles for commons fields along the backend background: var(--color-palette-primary-200); border-radius: 0.25rem; color: var(--color-palette-primary-500); - font-size: 0.813rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; + font-size: 0.75rem; + line-height: 0.374rem; + text-align: center; text-decoration: none; border: 1px solid rgba(0, 0, 0, 0.1); + width: auto; + max-width: 112px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-left: 1.25rem; + position: relative; } .tagLink.persona:before { content: '\f007'; display: flex; } .tagLink:after { + position: absolute; + left: 0.5rem; color: var(--color-palette-primary-500); content: '✕'; - font-size: 0.813rem; + font-size: 0.75rem; text-align: center; + line-height: 0.374rem; } .tagLink:hover { border: 1px solid var(--color-palette-primary-500); @@ -9556,7 +9592,7 @@ dl.vertical dt label { } .hint-text { - font-size: 11px; + font-size: 0.625rem; color: #999; display: block; line-height: normal; @@ -9867,7 +9903,7 @@ dd .buttonCaption { } .calendar-events .portlet-toolbar__info { flex: 1 0 auto; - font-size: 1.25rem; + font-size: 1rem; justify-content: center; } @@ -9968,7 +10004,7 @@ dd .buttonCaption { } .dayNumberSection { - font-size: 11px; + font-size: 0.625rem; } .dayNumber { @@ -10001,7 +10037,7 @@ dd .buttonCaption { .dayEventsSection span { font-weight: bold; text-transform: uppercase; - font-size: 77%; + font-size: 0.625rem; } /* NAV MENU STYLES */ @@ -10057,7 +10093,7 @@ dd .buttonCaption { #eventDetailTitle { font-weight: bold; - font-size: 1.2em; + font-size: 1rem; padding-bottom: 5px; width: 459; overflow: hidden; @@ -10234,7 +10270,7 @@ a.tag_higlighted { text-overflow: clip; white-space: nowrap; font-family: Monaco, Menlo, sans-serif; - font-size: 12px; + font-size: 0.625rem; } .aceTextTemplate, @@ -10251,7 +10287,7 @@ a.tag_higlighted { text-overflow: clip; white-space: nowrap; font-family: Monaco, Menlo, sans-serif; - font-size: 12px; + font-size: 0.625rem; } .content-edit__main { @@ -10427,12 +10463,9 @@ a.tag_higlighted { } .content-search__result-item { - padding-left: 24px; - position: relative; -} -.content-search__result-item .dijitCheckBox { - position: absolute; - left: 0; + display: flex; + align-items: center; + gap: 0.5rem; } .content-search__action-item { @@ -10657,7 +10690,7 @@ a.tag_higlighted { .nameText { align-self: center; - font-size: 1.25rem; + font-size: 1rem; font-weight: normal; } @@ -10775,7 +10808,7 @@ a.tag_higlighted { } .fullUserName { - font-size: 1.25rem; + font-size: 1rem; font-weight: bold; margin-bottom: 0.5rem; padding-bottom: 0; @@ -10790,11 +10823,6 @@ a.tag_higlighted { height: 6px; } -.portlet-sidebar .dijitCheckBox { - position: relative; - bottom: 2px; -} - .dijitContentPane .buttonRow { margin-bottom: 15px; } @@ -10919,9 +10947,7 @@ a.tag_higlighted { } .context-menu { - box-shadow: - 0 3px 6px var(--color-palette-black-op-10), - 0 3px 6px var(--color-palette-black-op-20); + box-shadow: 0px 4px 8px 0px hsla(230, 13%, 9%, 0.06); position: absolute; background: #ffffff; border: 1px solid #d1d4db; @@ -10936,7 +10962,7 @@ a.tag_higlighted { color: #515667; cursor: pointer; display: block; - font-size: 1rem; + font-size: 0.875rem; height: 2.5rem; line-height: 2.5rem; padding: 0px 1.5rem; @@ -10951,8 +10977,8 @@ a.tag_higlighted { display: none; } .context-menu__item .arrowRightIcon { - font-size: 0.813rem; - margin-top: -0.4065rem; + font-size: 0.75rem; + margin-top: -0.375rem; position: absolute; right: 0.5rem; top: 50%; @@ -11115,7 +11141,7 @@ a.tag_higlighted { position: absolute; } .file-selector-tree__card .label { - font-size: 16px; + font-size: 0.875rem; padding: 1.5rem 1rem; text-overflow: ellipsis; white-space: nowrap; @@ -11700,7 +11726,7 @@ Resize Handle border-radius: 0.375rem; color: #6c7389; font-family: Assistant, 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif; - font-size: 1rem; + font-size: 0.875rem; line-height: 2.5rem; padding-left: 0.5rem; padding-right: 0.5rem; @@ -11755,7 +11781,6 @@ Resize Handle html { font-family: Assistant, 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif; - font-size: 1rem; color: #14151a; } @@ -11764,6 +11789,31 @@ body { padding: 0; } +span, +label, +div, +dl, +dt, +dd, +ul, +ol, +li, +pre, +code, +form, +fieldset, +legend, +input, +button, +textarea, +select, +p, +blockquote, +th, +td { + font-size: 0.875rem; +} + .material-icons { font-family: 'Material Icons', sans-serif; font-weight: normal; diff --git a/core-web/libs/dotcms-scss/jsp/scss/backend/_base.scss b/core-web/libs/dotcms-scss/jsp/scss/backend/_base.scss index 3ce236a9088..d77491a7355 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/backend/_base.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/backend/_base.scss @@ -12,32 +12,33 @@ body { } h1 { - font-size: 174%; + font-size: $font-size-xl; } h2 { - font-size: 138.5%; + font-size: $font-size-lg; line-height: 115%; margin: 0 0 0.2em 0; font-weight: normal; } h3 { - font-size: 100%; + font-size: $font-size-md; margin: 0 0 0.2em 0; font-weight: bold; } h4 { + font-size: $font-size-md; font-weight: bold; } h5 { - font-size: 77%; + font-size: $font-size-sm; } h6 { - font-size: 77%; + font-size: $font-size-sm; font-style: italic; } @@ -99,7 +100,7 @@ ul { } .inputCaption { - font-size: 85%; + font-size: $font-size-sm; color: #888; font-style: italic; } @@ -127,12 +128,12 @@ kbd, samp, tt { font-family: monospace; - *font-size: 108%; + *font-size: $font-size-lmd; line-height: 99%; } sup { - font-size: 60%; + font-size: $font-size-xs; } abbr { diff --git a/core-web/libs/dotcms-scss/jsp/scss/backend/_calendar-blue.scss b/core-web/libs/dotcms-scss/jsp/scss/backend/_calendar-blue.scss index 8e8e75f7ebf..12f280dbe71 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/backend/_calendar-blue.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/backend/_calendar-blue.scss @@ -81,7 +81,7 @@ div.calendar { padding: 2px 4px 2px 2px; } .calendar tbody .day.othermonth { - font-size: 80%; + font-size: $font-size-sm; color: #bbb; } .calendar tbody .day.othermonth.oweekend { @@ -192,7 +192,7 @@ div.calendar { border: 1px solid #655; background: #def; color: #000; - font-size: 90%; + font-size: $font-size-sm; } .calendar .combo .label, diff --git a/core-web/libs/dotcms-scss/jsp/scss/backend/_reset-fonts-grids.scss b/core-web/libs/dotcms-scss/jsp/scss/backend/_reset-fonts-grids.scss index f89147fc6a1..63bd69f9fb9 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/backend/_reset-fonts-grids.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/backend/_reset-fonts-grids.scss @@ -77,7 +77,7 @@ h3, h4, h5, h6 { - font-size: 100%; + font-size: $font-size-md; font-weight: normal; } q:before, diff --git a/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_dot-admin.scss b/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_dot-admin.scss index c499fc1727d..16d355677c8 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_dot-admin.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_dot-admin.scss @@ -13,7 +13,7 @@ position: absolute; top: 4px; right: 30px; - font-size: 85%; + font-size: $font-size-sm; color: #ddd; } #admin-site-tools-div a { @@ -99,7 +99,7 @@ right: 30px; top: 34px; width: 225px; - font-size: 85%; + font-size: $font-size-sm; border: 1px solid $color-palette-gray-400; border-top: 0; background: #fff; @@ -195,7 +195,7 @@ .changeHost { cursor: pointer; float: right; - font-size: 85%; + font-size: $font-size-sm; line-height: 15px; margin: 6px 10px 0 0; padding: 0; @@ -414,7 +414,7 @@ tr.active { .excelDownload { text-align: right; padding: 5px 10px; - font-size: 85%; + font-size: $font-size-sm; } .excelDownload a { text-decoration: none; @@ -577,14 +577,14 @@ tr.active { overflow: auto; } .tagsBox a { - font-size: 93%; + font-size: $font-size-md; color: #999; } .tagsCaption { display: block; color: #999; line-height: 140%; - font-size: 80%; + font-size: $font-size-sm; margin: 3px 0 0 5px; } .suggestHeading { @@ -1211,7 +1211,7 @@ table.sTypeTable.sTypeItem { margin: 15px 10px 20px 10px; } .siteOverview span { - font-size: 300%; + font-size: $icon-xl; display: block; padding: 8px; } @@ -1229,7 +1229,7 @@ table.dojoxLegendNode td { #pieChartLegend td.dojoxLegendText { text-align: left; vertical-align: top; - font-size: 85%; + font-size: $font-size-sm; line-height: 131%; } #lineWrapper { @@ -1251,7 +1251,7 @@ table.dojoxLegendNode td { border-radius: 10px; color: #fff; text-align: center; - font-size: 131%; + font-size: $font-size-lg; } .noPie { background: url($pie-bg) no-repeat center top; diff --git a/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_top-navigation.scss b/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_top-navigation.scss index 4a8758b2b9c..fffdfa85630 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_top-navigation.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/_top-navigation.scss @@ -38,7 +38,7 @@ $dropdown-position: 43px; background: #fff; color: #5f5f5f; display: block; - font-size: 85%; + font-size: $font-size-sm; margin: 0; padding: 3px 10px; vertical-align: middle; @@ -101,7 +101,7 @@ $dropdown-position: 43px; .navMenu-title { color: #404040; - font-size: 93%; + font-size: $font-size-md; font-weight: 700; line-height: 14px; margin: 0; @@ -111,7 +111,7 @@ $dropdown-position: 43px; .navMenu-subtitle { color: #5f5f5f; - font-size: 77%; + font-size: $font-size-xs; margin: 0; overflow: hidden; padding: 0; diff --git a/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/portlets/_calendar-events.scss b/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/portlets/_calendar-events.scss index 1f03ca304fb..cd6f7c4e816 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/portlets/_calendar-events.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/backend/dot-admin/portlets/_calendar-events.scss @@ -158,7 +158,7 @@ .dayEventsSection span { font-weight: bold; text-transform: uppercase; - font-size: 77%; + font-size: $font-size-xs; } /* NAV MENU STYLES */ @@ -220,7 +220,7 @@ #eventDetailTitle { font-weight: bold; - font-size: 1.2em; + font-size: $font-size-lmd; padding-bottom: 5px; width: 459; overflow: hidden; diff --git a/core-web/libs/dotcms-scss/jsp/scss/document.scss b/core-web/libs/dotcms-scss/jsp/scss/document.scss index 267f18ddff5..6f9ec8093bb 100644 --- a/core-web/libs/dotcms-scss/jsp/scss/document.scss +++ b/core-web/libs/dotcms-scss/jsp/scss/document.scss @@ -1,3 +1,5 @@ +@use "variables" as *; + body, div, dl, @@ -49,14 +51,14 @@ acronym { border: 0; } h1 { - font-size: 1.5em; + font-size: $font-size-lg; font-weight: normal; line-height: 1em; margin-top: 1em; margin-bottom: 0; } h2 { - font-size: 1.1667em; + font-size: $font-size-lmd; font-weight: bold; line-height: 1.286em; margin-top: 1.929em; @@ -66,20 +68,20 @@ h3, h4, h5, h6 { - font-size: 1em; + font-size: $font-size-md; font-weight: bold; line-height: 1.5em; margin-top: 1.5em; margin-bottom: 0; } p { - font-size: 1em; + font-size: $font-size-md; margin-top: 1.5em; margin-bottom: 1.5em; line-height: 1.5em; } blockquote { - font-size: 0.916em; + font-size: $font-size-sm; margin-top: 3.272em; margin-bottom: 3.272em; line-height: 1.636em; @@ -89,7 +91,7 @@ blockquote { } ol li, ul li { - font-size: 1em; + font-size: $font-size-md; line-height: 1.5em; margin: 0; } diff --git a/core-web/libs/dotcms-webcomponents/src/elements/dot-material-icon-picker/dot-material-icon-picker.scss b/core-web/libs/dotcms-webcomponents/src/elements/dot-material-icon-picker/dot-material-icon-picker.scss index 305db1c9f63..71722e39c27 100644 --- a/core-web/libs/dotcms-webcomponents/src/elements/dot-material-icon-picker/dot-material-icon-picker.scss +++ b/core-web/libs/dotcms-webcomponents/src/elements/dot-material-icon-picker/dot-material-icon-picker.scss @@ -1,5 +1,6 @@ @import "../../../../dotcms-scss/shared/common"; @import "../../../../dotcms-scss/shared/colors"; +@import "../../../../dotcms-scss/shared/fonts"; dot-material-icon-picker { display: flex; @@ -27,7 +28,7 @@ dot-material-icon-picker { border: 1px solid #000; border-right: 0; display: flex; - font-size: 1em; + font-size: $font-size-md; padding-right: 0.2em; width: 1em; } @@ -38,7 +39,7 @@ dot-material-icon-picker { border-right: 0; border-bottom: 1px solid; box-sizing: border-box; - font-size: 1em; + font-size: $font-size-md; width: 100%; } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html index 9e0265812f1..95983b495ea 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html @@ -93,6 +93,18 @@ [contentlet]="contentlet" [field]="field" /> } + @case (fieldTypes.FILE) { + + } + @case (fieldTypes.IMAGE) { + + } } @if (field.hint) { {{ field.hint }} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts index 689402d8979..3ce0e4c95b2 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts @@ -25,6 +25,7 @@ import { DotEditContentCalendarFieldComponent } from '../../fields/dot-edit-cont import { DotEditContentCategoryFieldComponent } from '../../fields/dot-edit-content-category-field/dot-edit-content-category-field.component'; import { DotEditContentCheckboxFieldComponent } from '../../fields/dot-edit-content-checkbox-field/dot-edit-content-checkbox-field.component'; import { DotEditContentCustomFieldComponent } from '../../fields/dot-edit-content-custom-field/dot-edit-content-custom-field.component'; +import { DotEditContentFileFieldComponent } from '../../fields/dot-edit-content-file-field/dot-edit-content-file-field.component'; import { DotEditContentHostFolderFieldComponent } from '../../fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component'; import { DotEditContentJsonFieldComponent } from '../../fields/dot-edit-content-json-field/dot-edit-content-json-field.component'; import { DotEditContentKeyValueComponent } from '../../fields/dot-edit-content-key-value/dot-edit-content-key-value.component'; @@ -69,6 +70,8 @@ declare module '@tiptap/core' { const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTestBed> = { // We had to use unknown because components have different types. [FIELD_TYPES.TEXT]: DotEditContentTextFieldComponent, + [FIELD_TYPES.FILE]: DotEditContentFileFieldComponent, + [FIELD_TYPES.IMAGE]: DotEditContentFileFieldComponent, [FIELD_TYPES.TEXTAREA]: DotEditContentTextAreaComponent, [FIELD_TYPES.SELECT]: DotEditContentSelectFieldComponent, [FIELD_TYPES.RADIO]: DotEditContentRadioFieldComponent, diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts index 1fbe60b1380..a6560dd3c40 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts @@ -7,6 +7,7 @@ import { DotFieldRequiredDirective } from '@dotcms/ui'; import { DotEditContentBinaryFieldComponent } from '../../fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component'; import { DotEditContentFieldsModule } from '../../fields/dot-edit-content-fields.module'; +import { DotEditContentFileFieldComponent } from '../../fields/dot-edit-content-file-field/dot-edit-content-file-field.component'; import { DotEditContentKeyValueComponent } from '../../fields/dot-edit-content-key-value/dot-edit-content-key-value.component'; import { DotEditContentWYSIWYGFieldComponent } from '../../fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component'; import { CALENDAR_FIELD_TYPES } from '../../models/dot-edit-content-field.constant'; @@ -31,7 +32,8 @@ import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; BlockEditorModule, DotEditContentBinaryFieldComponent, DotEditContentKeyValueComponent, - DotEditContentWYSIWYGFieldComponent + DotEditContentWYSIWYGFieldComponent, + DotEditContentFileFieldComponent ] }) export class DotEditContentFieldComponent { diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts index bebd2cb78ca..4b897fbc0c3 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts @@ -25,6 +25,8 @@ const defaultResolutionFn: FnResolutionValue = (contentlet, field) => */ export const resolutionValue: Record = { [FIELD_TYPES.BINARY]: defaultResolutionFn, + [FIELD_TYPES.FILE]: defaultResolutionFn, + [FIELD_TYPES.IMAGE]: defaultResolutionFn, [FIELD_TYPES.BLOCK_EDITOR]: defaultResolutionFn, [FIELD_TYPES.CHECKBOX]: defaultResolutionFn, [FIELD_TYPES.CONSTANT]: defaultResolutionFn, diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-category-list/dot-category-field-category-list.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-category-list/dot-category-field-category-list.component.spec.ts index 59a291c5611..6088242e1b3 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-category-list/dot-category-field-category-list.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-category-list/dot-category-field-category-list.component.spec.ts @@ -14,7 +14,7 @@ import { CATEGORY_LIST_MOCK, CATEGORY_LIST_MOCK_TRANSFORMED_MATRIX, CATEGORY_MOCK_TRANSFORMED, - SELECTED_LIST_MOCK + MOCK_SELECTED_CATEGORIES_KEYS } from '../../mocks/category-field.mocks'; import { DotCategoryFieldListSkeletonComponent } from '../dot-category-field-list-skeleton/dot-category-field-list-skeleton.component'; @@ -29,7 +29,7 @@ describe('DotCategoryFieldCategoryListComponent', () => { beforeEach(() => { spectator = createComponent(); spectator.setInput('categories', CATEGORY_LIST_MOCK_TRANSFORMED_MATRIX); - spectator.setInput('selected', SELECTED_LIST_MOCK); + spectator.setInput('selected', MOCK_SELECTED_CATEGORIES_KEYS); spectator.setInput('state', ComponentStatus.INIT); spectator.setInput('breadcrumbs', []); @@ -81,7 +81,7 @@ describe('DotCategoryFieldCategoryListComponent', () => { it('should apply selected class to the correct item', () => { spectator.setInput('categories', [CATEGORY_MOCK_TRANSFORMED]); - spectator.setInput('selected', SELECTED_LIST_MOCK); + spectator.setInput('selected', MOCK_SELECTED_CATEGORIES_KEYS); spectator.setInput('state', ComponentStatus.LOADED); spectator.detectChanges(); @@ -97,7 +97,7 @@ describe('DotCategoryFieldCategoryListComponent', () => { const testCategories = Array(minColumns).fill(CATEGORY_LIST_MOCK_TRANSFORMED_MATRIX[0]); spectator.setInput('categories', testCategories); - spectator.setInput('selected', SELECTED_LIST_MOCK); + spectator.setInput('selected', MOCK_SELECTED_CATEGORIES_KEYS); spectator.setInput('state', ComponentStatus.LOADED); spectator.detectChanges(); @@ -107,7 +107,7 @@ describe('DotCategoryFieldCategoryListComponent', () => { it('should render the skeleton component if is loading', () => { spectator.setInput('categories', [CATEGORY_MOCK_TRANSFORMED]); - spectator.setInput('selected', SELECTED_LIST_MOCK); + spectator.setInput('selected', MOCK_SELECTED_CATEGORIES_KEYS); spectator.setInput('state', ComponentStatus.LOADING); spectator.detectChanges(); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-dialog/dot-category-field-dialog.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-dialog/dot-category-field-dialog.component.html index 367a38681cd..af74c00b7f3 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-dialog/dot-category-field-dialog.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-dialog/dot-category-field-dialog.component.html @@ -23,7 +23,7 @@ (rowClicked)="store.getCategories($event)" [categories]="store.categoryList()" [state]="store.listState()" - [selected]="store.confirmedCategoriesValues()" + [selected]="store.dialogSelectedKeys()" [breadcrumbs]="store.breadcrumbMenu()" /> } @else { + [selected]="store.dialog.selected()" /> }
- @if (store.selected().length) { + [ngClass]="{ empty: !store.dialog.selected().length }"> + @if (store.dialog.selected().length) {
+ [categories]="store.dialog.selected()" />
-@if ($showCategoriesDialog()) { - @defer (when $showCategoriesDialog()) { +@if (store.isDialogOpen()) { + @defer (when store.isDialogOpen()) { } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.spec.ts index 8783707f3d4..a7972aec4d5 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.spec.ts @@ -16,8 +16,10 @@ import { CATEGORY_FIELD_MOCK, CATEGORY_FIELD_VARIABLE_NAME, CATEGORY_HIERARCHY_MOCK, - SELECTED_LIST_MOCK + CATEGORY_LEVEL_2, + MOCK_SELECTED_CATEGORIES_OBJECT } from './mocks/category-field.mocks'; +import { DotCategoryFieldKeyValueObj } from './models/dot-category-field.models'; import { CategoriesService } from './services/categories.service'; import { CategoryFieldStore } from './store/content-category-field.store'; @@ -86,7 +88,7 @@ describe('DotEditContentCategoryFieldComponent', () => { it('should categoryFieldControl has the values loaded on the store', () => { const categoryValue = spectator.component.categoryFieldControl.value; - expect(categoryValue).toEqual(SELECTED_LIST_MOCK); + expect(categoryValue).toEqual(MOCK_SELECTED_CATEGORIES_OBJECT); }); }); @@ -186,30 +188,50 @@ describe('DotEditContentCategoryFieldComponent', () => { // Check if the form has the correct value const categoryValue = spectator.component.categoryFieldControl.value; - expect(categoryValue).toEqual(SELECTED_LIST_MOCK); + expect(categoryValue).toEqual(MOCK_SELECTED_CATEGORIES_OBJECT); })); it('should set categoryFieldControl value when adding a new category', () => { - store.addSelected({ - key: '1234', - value: 'test' - }); - store.addConfirmedCategories(); - spectator.flushEffects(); + const newItem: DotCategoryFieldKeyValueObj = { + key: CATEGORY_LEVEL_2[0].key, + value: CATEGORY_LEVEL_2[0].categoryName, + inode: CATEGORY_LEVEL_2[0].inode, + path: CATEGORY_LEVEL_2[0].categoryName + }; - const categoryValue = spectator.component.categoryFieldControl.value; + // this apply selected to the dialog + store.openDialog(); + + // Add the new category + store.addSelected(newItem); + // Apply the selected to the field + store.applyDialogSelection(); + + spectator.detectChanges(); + + const categoryValues = spectator.component.categoryFieldControl.value; - expect(categoryValue).toEqual([...SELECTED_LIST_MOCK, '1234']); + expect(categoryValues).toEqual([...MOCK_SELECTED_CATEGORIES_OBJECT, newItem]); }); it('should set categoryFieldControl value when removing a category', () => { - store.removeConfirmedCategories(SELECTED_LIST_MOCK[0]); + const categoryValue: DotCategoryFieldKeyValueObj[] = + spectator.component.categoryFieldControl.value; - spectator.flushEffects(); + const expectedSelected = [categoryValue[0]]; - const categoryValue = spectator.component.categoryFieldControl.value; + expect(categoryValue.length).toBe(2); + store.openDialog(); // this apply selected to the dialog + + store.removeSelected(categoryValue[1].key); + store.applyDialogSelection(); // this apply the selected in the dialog to the final selected + + spectator.detectChanges(); + + const newCategoryValue = spectator.component.categoryFieldControl.value; - expect(categoryValue).toEqual([SELECTED_LIST_MOCK[1]]); + expect(newCategoryValue).toEqual(expectedSelected); + expect(newCategoryValue.length).toBe(1); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts index 4059ee4e1ce..70148fa4e72 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/dot-edit-content-category-field.component.ts @@ -7,8 +7,7 @@ import { inject, Injector, input, - OnInit, - signal + OnInit } from '@angular/core'; import { ControlContainer, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; @@ -45,8 +44,8 @@ import { CategoryFieldStore } from './store/content-category-field.store'; styleUrl: './dot-edit-content-category-field.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { - '[class.dot-category-field__container--has-categories]': '$hasConfirmedCategories()', - '[class.dot-category-field__container]': '!$hasConfirmedCategories()' + '[class.dot-category-field__container--has-categories]': '$hasSelectedCategories()', + '[class.dot-category-field__container]': '!$hasSelectedCategories()' }, viewProviders: [ { @@ -60,10 +59,7 @@ export class DotEditContentCategoryFieldComponent implements OnInit { readonly store = inject(CategoryFieldStore); readonly #form = inject(ControlContainer).control as FormGroup; readonly #injector = inject(Injector); - /** - * Disable the button to open the dialog - */ - $showCategoriesDialog = signal(false); + /** * The `field` variable is of type `DotCMSContentTypeField` and is a required input. * @description The variable represents a field of a DotCMS content type and is a required input. @@ -79,7 +75,7 @@ export class DotEditContentCategoryFieldComponent implements OnInit { * * @returns {Boolean} - True if there are selected categories, false otherwise. */ - $hasConfirmedCategories = computed(() => !!this.store.hasConfirmedCategories()); + $hasSelectedCategories = computed(() => !!this.store.selected()); /** * Getter to retrieve the category field control. * @@ -100,7 +96,7 @@ export class DotEditContentCategoryFieldComponent implements OnInit { }); effect( () => { - const categoryValues = this.store.confirmedCategoriesValues(); + const categoryValues = this.store.selected(); if (this.categoryFieldControl) { this.categoryFieldControl.setValue(categoryValues); @@ -117,15 +113,6 @@ export class DotEditContentCategoryFieldComponent implements OnInit { * @memberof DotEditContentCategoryFieldComponent */ openCategoriesDialog(): void { - this.store.setSelectedCategories(); - this.$showCategoriesDialog.set(true); - } - /** - * Close the categories dialog. - * - * @memberof DotEditContentCategoryFieldComponent - */ - closeCategoriesDialog() { - this.$showCategoriesDialog.set(false); + this.store.openDialog(); } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/mocks/category-field.mocks.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/mocks/category-field.mocks.ts index b21f5883db2..955f2ac004a 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/mocks/category-field.mocks.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/mocks/category-field.mocks.ts @@ -15,7 +15,7 @@ export const CATEGORY_FIELD_CONTENTLET_MOCK: DotCMSContentlet = { baseType: 'CONTENT', [CATEGORY_FIELD_VARIABLE_NAME]: [ { - '1f208488057007cedda0e0b5d52ee3b3': 'Electrical' + '1f208488057007cedda0e0b5d52ee3b3': 'Cleaning Supplies' }, { cb83dc32c0a198fd0ca427b3b587f4ce: 'Doors & Windows' @@ -197,14 +197,32 @@ export const CATEGORY_LEVEL_2: DotCategory[] = [ export const CATEGORY_LIST_MOCK: DotCategory[][] = [[...CATEGORY_LEVEL_1], [...CATEGORY_LEVEL_2]]; /** - * Represent the selected categories + * Represent the selected categories keys */ -export const SELECTED_LIST_MOCK = [CATEGORY_LEVEL_1[0].key, CATEGORY_LEVEL_1[1].key]; +export const MOCK_SELECTED_CATEGORIES_KEYS = [CATEGORY_LEVEL_1[0].key, CATEGORY_LEVEL_1[1].key]; + +/** + * Represent the selected categories as an object + */ +export const MOCK_SELECTED_CATEGORIES_OBJECT: DotCategoryFieldKeyValueObj[] = [ + { + key: CATEGORY_LEVEL_1[0].key, + value: CATEGORY_LEVEL_1[0].categoryName, + inode: CATEGORY_LEVEL_1[0].inode, + path: CATEGORY_LEVEL_1[0].categoryName // root categories is the categoryName + }, + { + key: CATEGORY_LEVEL_1[1].key, + value: CATEGORY_LEVEL_1[1].categoryName, + inode: CATEGORY_LEVEL_1[1].inode, + path: CATEGORY_LEVEL_1[1].categoryName // root categories is the categoryName + } +]; export const CATEGORY_LIST_MOCK_TRANSFORMED_MATRIX: DotCategoryFieldKeyValueObj[][] = CATEGORY_LIST_MOCK.map( (categoryLevel) => transformCategories(categoryLevel) as DotCategoryFieldKeyValueObj[], - SELECTED_LIST_MOCK + MOCK_SELECTED_CATEGORIES_KEYS ); export const CATEGORY_MOCK_TRANSFORMED: DotCategoryFieldKeyValueObj[] = [ @@ -295,9 +313,9 @@ export const CATEGORY_HIERARCHY_MOCK: HierarchyParent[] = [ name: CATEGORY_FIELD_MOCK.categories.categoryName }, { - inode: CATEGORY_LEVEL_1[0].inode, - key: CATEGORY_LEVEL_1[0].key, - name: CATEGORY_LEVEL_1[0].categoryName + inode: CATEGORY_LEVEL_1[1].inode, + key: CATEGORY_LEVEL_1[1].key, + name: CATEGORY_LEVEL_1[1].categoryName } ] } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.spec.ts index d5bf4bb2338..d85dd5bd3b0 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.spec.ts @@ -17,7 +17,7 @@ import { CATEGORY_HIERARCHY_MOCK, CATEGORY_LEVEL_1, CATEGORY_LEVEL_2, - SELECTED_LIST_MOCK + MOCK_SELECTED_CATEGORIES_OBJECT } from '../mocks/category-field.mocks'; import { DotCategoryFieldKeyValueObj } from '../models/dot-category-field.models'; import { CategoriesService } from '../services/categories.service'; @@ -52,29 +52,18 @@ describe('CategoryFieldStore', () => { expect(store.keyParentPath()).toEqual(EMPTY_ARRAY); expect(store.state()).toEqual(ComponentStatus.INIT); expect(store.selected()).toEqual(EMPTY_ARRAY); - expect(store.confirmedCategories()).toEqual(EMPTY_ARRAY); + expect(store.dialog.selected()).toEqual(EMPTY_ARRAY); + expect(store.dialog.state()).toEqual('closed'); expect(store.mode()).toEqual('list'); }); describe('withMethods', () => { it('should set the correct rootCategoryInode and categoriesValue', () => { const expectedCategoryValues: DotCategoryFieldKeyValueObj[] = [ - { - key: '1f208488057007cedda0e0b5d52ee3b3', - value: 'Cleaning Supplies', - inode: '111111', - path: 'Cleaning Supplies' - }, - { - key: 'cb83dc32c0a198fd0ca427b3b587f4ce', - value: 'Doors & Windows', - inode: '22222', - path: 'Cleaning Supplies' - } + ...MOCK_SELECTED_CATEGORIES_OBJECT ]; store.load({ field: CATEGORY_FIELD_MOCK, contentlet: CATEGORY_FIELD_CONTENTLET_MOCK }); - expect(store.selected()).toEqual(expectedCategoryValues); expect(store.rootCategoryInode()).toEqual(CATEGORY_FIELD_MOCK.values); }); @@ -124,43 +113,93 @@ describe('CategoryFieldStore', () => { expect(store.categories().length).toBe(2); }); }); + }); - it('should remove confirmed categories with given key', () => { - store.addSelected([{ key: '1234', value: 'test' }]); - store.addConfirmedCategories(); - - store.removeConfirmedCategories('1234'); - expect(store.confirmedCategories().length).toEqual(0); + describe('Dialog', () => { + beforeEach(() => { + store.load({ field: CATEGORY_FIELD_MOCK, contentlet: CATEGORY_FIELD_CONTENTLET_MOCK }); + store.openDialog(); }); - it('should add selected categories to confirmed categories', () => { - store.addSelected([{ key: '1234', value: 'test' }]); - store.addConfirmedCategories(); - - expect(store.confirmedCategories()).toEqual(store.selected()); + describe('openDialog', () => { + it('should set the dialog state to open and copy selected items', () => { + expect(store.dialog.state()).toBe('open'); + expect(store.dialog.selected()).toEqual(MOCK_SELECTED_CATEGORIES_OBJECT); + }); }); - it('should set selected categories based on confirmed categories', () => { - store.addSelected([{ key: '1234', value: 'test' }]); - store.addConfirmedCategories(); - - store.removeSelected('1234'); - - expect(store.selected()).toEqual(EMPTY_ARRAY); + describe('closeDialog', () => { + it('should set the dialog state to closed and clear selected items', () => { + store.closeDialog(); + expect(store.dialog.state()).toBe('closed'); + expect(store.dialog.selected()).toEqual(EMPTY_ARRAY); + expect(store.dialog.selected()).toEqual(EMPTY_ARRAY); + }); + }); - store.setSelectedCategories(); + describe('updateSelected', () => { + it('should add a new item to the dialog selected items', () => { + expect(store.dialog.selected().length).toBe(MOCK_SELECTED_CATEGORIES_OBJECT.length); + + const newItem: DotCategoryFieldKeyValueObj = { + key: CATEGORY_LEVEL_2[0].key, + value: CATEGORY_LEVEL_2[0].categoryName, + inode: CATEGORY_LEVEL_2[0].inode, + path: CATEGORY_LEVEL_2[0].categoryName + }; + const selectedKeys = [ + ...MOCK_SELECTED_CATEGORIES_OBJECT.map((item) => item.key), + newItem.key + ]; + store.updateSelected(selectedKeys, newItem); + + const expectedItems = [...MOCK_SELECTED_CATEGORIES_OBJECT, newItem]; + + expect(store.dialog.selected()).toEqual(expectedItems); + expect(store.dialog.selected().length).toBe( + MOCK_SELECTED_CATEGORIES_OBJECT.length + 1 + ); + }); + }); - expect(store.selected()).toEqual(store.confirmedCategories()); + describe('applyDialogSelection', () => { + it("should update the store's selected items with the dialog's selected items", () => { + const newItem: DotCategoryFieldKeyValueObj = { + key: CATEGORY_LEVEL_2[0].key, + value: CATEGORY_LEVEL_2[0].categoryName, + inode: CATEGORY_LEVEL_2[0].inode, + path: CATEGORY_LEVEL_2[0].categoryName + }; + + store.updateSelected( + [...MOCK_SELECTED_CATEGORIES_OBJECT.map((item) => item.key), newItem.key], + newItem + ); + store.applyDialogSelection(); + + expect(store.selected()).toEqual([...MOCK_SELECTED_CATEGORIES_OBJECT, newItem]); + }); }); - }); - describe('withComputed', () => { - it('should show item after load the values', () => { - const expectedSelectedValues = SELECTED_LIST_MOCK; - store.load({ field: CATEGORY_FIELD_MOCK, contentlet: CATEGORY_FIELD_CONTENTLET_MOCK }); - expect(store.confirmedCategoriesValues().sort()).toEqual(expectedSelectedValues.sort()); + describe('removeSelected', () => { + it('should remove a single item by key from the dialog selected items', () => { + store.removeSelected(MOCK_SELECTED_CATEGORIES_OBJECT[0].key); + + expect(store.dialog.selected().length).toBe( + MOCK_SELECTED_CATEGORIES_OBJECT.length - 1 + ); + expect( + store.dialog + .selected() + .find((item) => item.key === MOCK_SELECTED_CATEGORIES_OBJECT[0].key) + ).toBeUndefined(); + }); - expect(store.categoryList()).toEqual(EMPTY_ARRAY); + it('should remove multiple items by keys from the dialog selected items', () => { + store.removeSelected(MOCK_SELECTED_CATEGORIES_OBJECT.map((item) => item.key)); + + expect(store.dialog.selected()).toEqual(EMPTY_ARRAY); + }); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts index f3d7b649e1f..03e417b8163 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/store/content-category-field.store.ts @@ -36,7 +36,6 @@ import { export type CategoryFieldState = { field: DotCMSContentTypeField; selected: DotCategoryFieldKeyValueObj[]; // <- source of selected - confirmedCategories: DotCategoryFieldKeyValueObj[]; // <- source of confirmed categories in the modal. categories: DotCategory[][]; keyParentPath: string[]; // Delete when we have the endpoint for this state: ComponentStatus; @@ -44,18 +43,26 @@ export type CategoryFieldState = { // search filter: string; searchCategories: DotCategory[]; + //dialog + dialog: { + selected: DotCategoryFieldKeyValueObj[]; + state: 'open' | 'closed'; + }; }; export const initialState: CategoryFieldState = { field: {} as DotCMSContentTypeField, selected: [], - confirmedCategories: [], categories: [], keyParentPath: [], state: ComponentStatus.INIT, mode: 'list', filter: '', - searchCategories: [] + searchCategories: [], + dialog: { + selected: [], + state: 'closed' + } }; /** @@ -66,13 +73,6 @@ export const initialState: CategoryFieldState = { export const CategoryFieldStore = signalStore( withState(initialState), withComputed((store) => ({ - /** - * Current confirmed Categories items (key) from the contentlet - */ - confirmedCategoriesValues: computed(() => - store.confirmedCategories().map((item) => item.key) - ), - /** * Categories for render with added properties */ @@ -80,11 +80,6 @@ export const CategoryFieldStore = signalStore( store.categories().map((column) => transformCategories(column, store.keyParentPath())) ), - /** - * Indicates whether any categories are confirmed. - */ - hasConfirmedCategories: computed(() => !!store.confirmedCategories().length), - /** * Get the root category inode. */ @@ -143,7 +138,21 @@ export const CategoryFieldStore = signalStore( const keyParentPath = store.keyParentPath(); return getMenuItemsFromKeyParentPath(categories, keyParentPath); - }) + }), + + // Dialog + /** + * Computed property that checks if a dialog is open. + */ + isDialogOpen: computed(() => store.dialog.state() === 'open'), + + /** + * A computed property that retrieves the keys of selected dialog items. + * This function accesses the store's dialog and maps each selected item's key. + * + * @returns {Array} An array of keys of selected dialog items. + */ + dialogSelectedKeys: computed(() => store.dialog.selected().map((item) => item.key)) })), withMethods( ( @@ -169,13 +178,12 @@ export const CategoryFieldStore = signalStore( return categoryService.getSelectedHierarchy(selectedKeys).pipe( tapResponse({ next: (categoryWithParentPath) => { - const confirmedCategories = + const selected = transformToSelectedObject(categoryWithParentPath); patchState(store, { field, - selected: confirmedCategories, - confirmedCategories, + selected, state: ComponentStatus.LOADED }); }, @@ -204,6 +212,30 @@ export const CategoryFieldStore = signalStore( }); }, + /** + * Opens the dialog with the current selected items and sets the state to 'open'. + */ + openDialog(): void { + patchState(store, { + dialog: { + selected: [...store.selected()], + state: 'open' + } + }); + }, + + /** + * Closes the dialog and resets the selected items. + */ + closeDialog(): void { + patchState(store, { + dialog: { + selected: [], + state: 'closed' + } + }); + }, + /** * Updates the selected items based on the items keyed by the provided selected keys. * This method receives the selected keys from the list of categories, searches in the category array @@ -214,13 +246,13 @@ export const CategoryFieldStore = signalStore( */ updateSelected(categoryListChecked: string[], item: DotCategoryFieldKeyValueObj): void { const currentChecked: DotCategoryFieldKeyValueObj[] = updateChecked( - store.selected(), + store.dialog.selected(), categoryListChecked, item ); patchState(store, { - selected: currentChecked + dialog: { ...store.dialog(), selected: [...currentChecked] } }); }, @@ -233,66 +265,50 @@ export const CategoryFieldStore = signalStore( addSelected( selectedItem: DotCategoryFieldKeyValueObj | DotCategoryFieldKeyValueObj[] ): void { - const updatedSelected = addSelected(store.selected(), selectedItem); + const updatedSelected = addSelected(store.dialog.selected(), selectedItem); patchState(store, { - selected: updatedSelected + dialog: { state: 'open', selected: updatedSelected } }); }, /** - * Removes the selected items with the given key(s). - * - * @param {string | string[]} key - The key(s) of the item(s) to be removed. - * @return {void} + * Applies the selected items from the dialog to the store. */ - removeSelected(key: string | string[]): void { - const newSelected = removeItemByKey(store.selected(), key); - + applyDialogSelection(): void { + const { selected } = store.dialog(); patchState(store, { - selected: newSelected + selected }); }, /** - * Removes the confirmed categories with the given key(s). + * Removes the selected at the dialog items with the given key(s). * * @param {string | string[]} key - The key(s) of the item(s) to be removed. * @return {void} */ - removeConfirmedCategories(key: string | string[]): void { - const newConfirmed = removeItemByKey(store.confirmedCategories(), key); + removeSelected(key: string | string[]): void { + const selected = removeItemByKey(store.dialog.selected(), key); patchState(store, { - confirmedCategories: newConfirmed + dialog: { ...store.dialog(), selected } }); }, /** - * Adds the selected categories to the confirmed categories in the store. - * This method is used when the user confirms the selection of categories in the Dialog. + * Removes the selected items with the given key(s). * + * @param {string | string[]} key - The key(s) of the item(s) to be removed. * @return {void} */ - addConfirmedCategories(): void { - patchState(store, { - confirmedCategories: store.selected() - }); - }, + removeRootSelected(key: string | string[]): void { + const selected = removeItemByKey(store.selected(), key); - /** - * Sets the selected categories in the store to the confirmed categories. - * This method is used when the user open the Dialog of categories. - * - * @return {void} - */ - setSelectedCategories(): void { - patchState(store, { - selected: store.confirmedCategories() - }); + patchState(store, { selected }); }, /** - * Clears all categories from the store, effectively resetting state related to categories and their parent paths. + * Resets the store to its initial state. */ clean() { patchState(store, { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/utils/category-field.utils.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/utils/category-field.utils.spec.ts index 4460829b522..5492f74c8be 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/utils/category-field.utils.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/utils/category-field.utils.spec.ts @@ -21,6 +21,10 @@ import { } from '../mocks/category-field.mocks'; import { DotCategoryFieldKeyValueObj } from '../models/dot-category-field.models'; +const MOCK_SELECTED_OBJECT: DotCategoryFieldKeyValueObj[] = [ + { key: '1f208488057007cedda0e0b5d52ee3b3', value: 'Cleaning Supplies' }, + { key: 'cb83dc32c0a198fd0ca427b3b587f4ce', value: 'Doors & Windows' } +]; describe('CategoryFieldUtils', () => { describe('getSelectedCategories', () => { it('should return an empty array if contentlet is null', () => { @@ -29,10 +33,7 @@ describe('CategoryFieldUtils', () => { }); it('should return parsed the values', () => { - const expected: DotCategoryFieldKeyValueObj[] = [ - { key: '1f208488057007cedda0e0b5d52ee3b3', value: 'Electrical' }, - { key: 'cb83dc32c0a198fd0ca427b3b587f4ce', value: 'Doors & Windows' } - ]; + const expected: DotCategoryFieldKeyValueObj[] = MOCK_SELECTED_OBJECT; const result = getSelectedFromContentlet( CATEGORY_FIELD_MOCK, CATEGORY_FIELD_CONTENTLET_MOCK @@ -605,10 +606,7 @@ describe('CategoryFieldUtils', () => { CATEGORY_FIELD_MOCK, CATEGORY_FIELD_CONTENTLET_MOCK ); - expect(result).toEqual([ - { key: '1f208488057007cedda0e0b5d52ee3b3', value: 'Electrical' }, - { key: 'cb83dc32c0a198fd0ca427b3b587f4ce', value: 'Doors & Windows' } - ]); + expect(result).toEqual(MOCK_SELECTED_OBJECT); }); it('should handle empty selected categories in contentlet', () => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html new file mode 100644 index 00000000000..f0dc6ce69bc --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html @@ -0,0 +1 @@ +

Preview

diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts new file mode 100644 index 00000000000..00e7c8b5ec2 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts @@ -0,0 +1,12 @@ +import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'dot-file-field-preview', + standalone: true, + imports: [], + providers: [], + templateUrl: './dot-file-field-preview.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class DotFileFieldPreviewComponent {} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html new file mode 100644 index 00000000000..56e014741fc --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.html @@ -0,0 +1,10 @@ +
+ +
+
+ + +
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss new file mode 100644 index 00000000000..fccd0d7f5e1 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.scss @@ -0,0 +1,36 @@ +@use "variables" as *; + +:host { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: $spacing-3; + height: 100%; + padding: $spacing-3; +} + +.icon-container { + border-radius: 50%; + padding: $spacing-3; + + .icon { + font-size: $font-size-xxl; + width: auto; + } + + &.info { + color: $color-palette-primary-500; + background: $color-palette-primary-200; + } + + &.error { + color: $color-alert-yellow; + background: $color-alert-yellow-light; + } +} + +.text { + text-align: center; + line-height: 140%; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts new file mode 100644 index 00000000000..0662de9f92e --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-ui-message/dot-file-field-ui-message.component.ts @@ -0,0 +1,16 @@ +import { NgClass } from '@angular/common'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { UIMessage } from '../../models'; + +@Component({ + selector: 'dot-file-field-ui-message', + standalone: true, + imports: [NgClass], + templateUrl: './dot-file-field-ui-message.component.html', + styleUrls: ['./dot-file-field-ui-message.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotFileFieldUiMessageComponent { + $uiMessage = input.required({ alias: 'uiMessage' }); +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html new file mode 100644 index 00000000000..fb0a4e9705b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html @@ -0,0 +1,83 @@ +
+ @switch (store.fileStatus()) { + @case ('init') { +
+ + @if (store.uiMessage()) { + + + + } + + +
+ +
+ @if (store.allowURLImport()) { + + } + @if (store.allowExistingFile()) { + + } + @if (store.allowCreateFile()) { + + } + @if (store.allowGenerateImg()) { + + + + + + + + {{ 'dot.file.field.action.generate.with.dotai' | dm }} + + + } +
+ } + @case ('uploading') { + + } + @case ('preview') { + + } + } +
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss new file mode 100644 index 00000000000..84690d8c28b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.scss @@ -0,0 +1,98 @@ +@use "variables" as *; + +:host { + display: block; + container-type: inline-size; + container-name: binaryField; +} + +.file-field__container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + border-radius: $border-radius-md; + border: $field-border-size solid $color-palette-gray-400; + padding: $spacing-1; + height: 14.4rem; + min-width: 12.5rem; + + &:has(.file-field__actions:empty) { + gap: 0; + } +} + +.file-field__container--uploading { + border: $field-border-size dashed $color-palette-gray-400; +} + +.file-field__actions { + display: flex; + flex-direction: column; + gap: $spacing-3; + justify-content: center; + align-items: flex-start; + + &:empty { + display: none; + } + + .label-ai { + text-transform: none; + font-size: $font-size-sm; + } + + .p-button { + display: inline-flex; + user-select: none; + align-items: center; + vertical-align: bottom; + text-align: center; + } +} + +.file-field__drop-zone { + border: $field-border-size dashed $input-border-color; + border-radius: $border-radius-md; + height: 100%; + flex: 1; + overflow: auto; + margin-right: $spacing-1; +} + +.file-field__drop-zone-btn { + border: none; + background: none; + color: $color-palette-primary-500; + text-decoration: underline; + font-size: $font-size-md; + font-family: $font-default; + padding: revert; + cursor: pointer; +} + +.file-field__drop-zone--active { + border-radius: $border-radius-md; + border-color: $color-palette-secondary-500; + background: $white; + box-shadow: $shadow-l; +} + +input[type="file"] { + display: none; +} + +@container fileField (max-width: 306px) { + .file-field__container--empty { + height: auto; + flex-direction: column; + justify-content: center; + align-items: flex-start; + } + + .file-field__drop-zone { + width: 100%; + margin: 0; + margin-bottom: $spacing-1; + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts new file mode 100644 index 00000000000..371a6a45842 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts @@ -0,0 +1,120 @@ +import { Spectator, byTestId, createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; + +import { provideHttpClient } from '@angular/common/http'; +import { ControlContainer } from '@angular/forms'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotDropZoneComponent } from '@dotcms/ui'; + +import { DotEditContentFileFieldComponent } from './dot-edit-content-file-field.component'; +import { FileFieldStore } from './store/file-field.store'; + +import { + BINARY_FIELD_MOCK, + createFormGroupDirectiveMock, + FILE_FIELD_MOCK, + IMAGE_FIELD_MOCK +} from '../../utils/mocks'; + +describe('DotEditContentFileFieldComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: DotEditContentFileFieldComponent, + detectChanges: false, + componentProviders: [FileFieldStore], + providers: [provideHttpClient(), mockProvider(DotMessageService)], + componentViewProviders: [ + { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } + ] + }); + + describe('FileField', () => { + beforeEach( + () => + (spectator = createComponent({ + props: { + field: FILE_FIELD_MOCK + } as unknown + })) + ); + + it('should be created', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should have a DotDropZoneComponent', () => { + spectator.detectChanges(); + + expect(spectator.query(DotDropZoneComponent)).toBeTruthy(); + }); + + it('should show the proper actions', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('action-import-from-url'))).toBeTruthy(); + expect(spectator.query(byTestId('action-existing-file'))).toBeTruthy(); + expect(spectator.query(byTestId('action-new-file'))).toBeTruthy(); + expect(spectator.query(byTestId('action-generate-with-ai'))).toBeFalsy(); + }); + }); + + describe('ImageField', () => { + beforeEach( + () => + (spectator = createComponent({ + props: { + field: IMAGE_FIELD_MOCK + } as unknown + })) + ); + + it('should be created', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should have a DotDropZoneComponent', () => { + spectator.detectChanges(); + + expect(spectator.query(DotDropZoneComponent)).toBeTruthy(); + }); + + it('should show the proper actions', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('action-import-from-url'))).toBeTruthy(); + expect(spectator.query(byTestId('action-existing-file'))).toBeTruthy(); + expect(spectator.query(byTestId('action-new-file'))).toBeFalsy(); + expect(spectator.query(byTestId('action-generate-with-ai'))).toBeTruthy(); + }); + }); + + describe('BinaryField', () => { + beforeEach( + () => + (spectator = createComponent({ + props: { + field: BINARY_FIELD_MOCK + } as unknown + })) + ); + + it('should be created', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should have a DotDropZoneComponent', () => { + spectator.detectChanges(); + + expect(spectator.query(DotDropZoneComponent)).toBeTruthy(); + }); + + it('should show the proper actions', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('action-import-from-url'))).toBeTruthy(); + expect(spectator.query(byTestId('action-existing-file'))).toBeFalsy(); + expect(spectator.query(byTestId('action-new-file'))).toBeTruthy(); + expect(spectator.query(byTestId('action-generate-with-ai'))).toBeTruthy(); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.stories.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.stories.ts new file mode 100644 index 00000000000..38d3e6119be --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.stories.ts @@ -0,0 +1,76 @@ +import { + moduleMetadata, + StoryObj, + Meta, + applicationConfig, + argsToTemplate +} from '@storybook/angular'; + +import { provideHttpClient } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +import { DotEditContentFileFieldComponent } from './dot-edit-content-file-field.component'; +import { FileFieldStore } from './store/file-field.store'; +import { MessageServiceMock } from './utils/mocks'; + +import { FILE_FIELD_MOCK, IMAGE_FIELD_MOCK, BINARY_FIELD_MOCK } from '../../utils/mocks'; + +type Args = DotEditContentFileFieldComponent & { + field: DotCMSContentTypeField; + value: string; +}; + +const meta: Meta = { + title: 'Library / Edit Content / File Field', + component: DotEditContentFileFieldComponent, + decorators: [ + applicationConfig({ + providers: [ + provideHttpClient(), + { + provide: DotMessageService, + useValue: MessageServiceMock + } + ] + }), + moduleMetadata({ + imports: [BrowserAnimationsModule, FormsModule], + providers: [FileFieldStore] + }) + ], + render: (args) => ({ + props: args, + template: ` + +

Current value: {{ value }}

+ ` + }) +}; +export default meta; + +type Story = StoryObj; + +export const FileField: Story = { + args: { + value: '', + field: { ...FILE_FIELD_MOCK } + } +}; + +export const ImageField: Story = { + args: { + value: '', + field: { ...IMAGE_FIELD_MOCK } + } +}; + +export const BinaryField: Story = { + args: { + value: '', + field: { ...BINARY_FIELD_MOCK } + } +}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts new file mode 100644 index 00000000000..1b652b4f6ca --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts @@ -0,0 +1,84 @@ +import { + ChangeDetectionStrategy, + Component, + forwardRef, + inject, + input, + signal, + OnInit +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { + DotDropZoneComponent, + DotMessagePipe, + DotAIImagePromptComponent, + DotSpinnerModule +} from '@dotcms/ui'; + +import { DotFileFieldPreviewComponent } from './components/dot-file-field-preview/dot-file-field-preview.component'; +import { DotFileFieldUiMessageComponent } from './components/dot-file-field-ui-message/dot-file-field-ui-message.component'; +import { INPUT_TYPES } from './models'; +import { FileFieldStore } from './store/file-field.store'; + +@Component({ + selector: 'dot-edit-content-file-field', + standalone: true, + imports: [ + ButtonModule, + DotMessagePipe, + DotDropZoneComponent, + DotAIImagePromptComponent, + DotSpinnerModule, + DotFileFieldUiMessageComponent, + DotFileFieldPreviewComponent + ], + providers: [ + FileFieldStore, + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DotEditContentFileFieldComponent) + } + ], + templateUrl: './dot-edit-content-file-field.component.html', + styleUrls: ['./dot-edit-content-file-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotEditContentFileFieldComponent implements ControlValueAccessor, OnInit { + readonly store = inject(FileFieldStore); + readonly #messageService = inject(DotMessageService); + + $field = input.required({ alias: 'field' }); + + private onChange: (value: string) => void; + private onTouched: () => void; + + $value = signal(''); + + ngOnInit() { + this.store.initLoad({ + inputType: this.$field().fieldType as INPUT_TYPES, + uiMessage: { + message: this.#messageService.get('dot.file.field.drag.and.drop.message'), + severity: 'info', + icon: 'pi pi-upload' + } + }); + } + + writeValue(value: string): void { + this.$value.set(value); + } + registerOnChange(fn: (value: string) => void) { + this.onChange = fn; + } + + registerOnTouched(fn: () => void) { + this.onTouched = fn; + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts new file mode 100644 index 00000000000..1b223074042 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts @@ -0,0 +1,31 @@ +import { INPUT_TYPES } from './models'; + +type Actions = { + allowExistingFile: boolean; + allowURLImport: boolean; + allowCreateFile: boolean; + allowGenerateImg: boolean; +}; + +type ConfigActions = Record; + +export const INPUT_CONFIG_ACTIONS: ConfigActions = { + File: { + allowExistingFile: true, + allowURLImport: true, + allowCreateFile: true, + allowGenerateImg: false + }, + Image: { + allowExistingFile: true, + allowURLImport: true, + allowCreateFile: false, + allowGenerateImg: true + }, + Binary: { + allowExistingFile: false, + allowURLImport: true, + allowCreateFile: true, + allowGenerateImg: true + } +}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts new file mode 100644 index 00000000000..adbe5448d19 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/models/index.ts @@ -0,0 +1,9 @@ +export type INPUT_TYPES = 'File' | 'Image' | 'Binary'; + +export type FILE_STATUS = 'init' | 'uploading' | 'preview'; + +export interface UIMessage { + message: string; + severity: 'info' | 'error' | 'warning' | 'success'; + icon: string; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts new file mode 100644 index 00000000000..bc82b878d76 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts @@ -0,0 +1,72 @@ +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; + +import { computed } from '@angular/core'; + +import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { INPUT_CONFIG_ACTIONS } from '../dot-edit-content-file-field.const'; +import { INPUT_TYPES, FILE_STATUS, UIMessage } from '../models'; + +export interface FileFieldState { + contentlet: DotCMSContentlet | null; + tempFile: DotCMSTempFile | null; + value: string; + inputType: INPUT_TYPES | null; + fileStatus: FILE_STATUS; + dropZoneActive: boolean; + isEnterprise: boolean; + isAIPluginInstalled: boolean; + allowURLImport: boolean; + allowGenerateImg: boolean; + allowExistingFile: boolean; + allowCreateFile: boolean; + uiMessage: UIMessage | null; +} + +const initialState: FileFieldState = { + contentlet: null, + tempFile: null, + value: '', + inputType: null, + fileStatus: 'init', + dropZoneActive: false, + isEnterprise: false, + isAIPluginInstalled: false, + allowURLImport: false, + allowGenerateImg: false, + allowExistingFile: false, + allowCreateFile: false, + uiMessage: null +}; + +export const FileFieldStore = signalStore( + withState(initialState), + withComputed(({ fileStatus }) => ({ + isEmpty: computed(() => { + const currentStatus = fileStatus(); + + return currentStatus === 'init' || currentStatus === 'preview'; + }), + isUploading: computed(() => { + const currentStatus = fileStatus(); + + return currentStatus === 'uploading'; + }) + })), + withMethods((store) => ({ + initLoad: (initState: { + inputType: FileFieldState['inputType']; + uiMessage: FileFieldState['uiMessage']; + }) => { + const { inputType, uiMessage } = initState; + + const actions = INPUT_CONFIG_ACTIONS[inputType] || {}; + + patchState(store, { + inputType, + uiMessage, + ...actions + }); + } + })) +); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts new file mode 100644 index 00000000000..12e5092f876 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/mocks.ts @@ -0,0 +1,21 @@ +import { MockDotMessageService } from '@dotcms/utils-testing'; + +const FILE_MESSAGES_MOCK = { + 'dot.file.field.action.choose.file': 'Choose File', + 'dot.file.field.action.create.new.file': 'Create New File', + 'dot.file.field.action.create.new.file.label': 'File Name', + 'dot.file.field.action.import.from.url.error.message': + 'The URL you requested is not valid. Please try again.', + 'dot.file.field.action.import.from.url': 'Import from URL', + 'dot.file.field.action.remove': 'Remove', + 'dot.file.field.drag.and.drop.message': 'Drag and Drop or', + 'dot.file.field.action.select.existing.file': 'Select Existing File', + 'dot.common.cancel': 'Cancel', + 'dot.common.edit': 'Edit', + 'dot.common.import': 'Import', + 'dot.common.remove': 'Remove', + 'dot.common.save': 'Save', + 'error.form.validator.required': 'This field is required' +}; + +export const MessageServiceMock = new MockDotMessageService(FILE_MESSAGES_MOCK); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html new file mode 100644 index 00000000000..bc37b961960 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.html @@ -0,0 +1,6 @@ + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.scss new file mode 100644 index 00000000000..a58223fb3de --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.scss @@ -0,0 +1,9 @@ +@use "variables" as *; + +ngx-monaco-editor { + height: 300px; + width: 100%; + border: $field-border-size solid $color-palette-gray-400; + border-radius: $border-radius-md; + display: flex; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.spec.ts new file mode 100644 index 00000000000..9f81f7936c3 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.spec.ts @@ -0,0 +1,79 @@ +import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; +import { createComponentFactory, Spectator } from '@ngneat/spectator'; + +import { ControlContainer } from '@angular/forms'; + +import { monacoMock } from '@dotcms/utils-testing'; + +import { DotWysiwygMonacoComponent } from './dot-wysiwyg-monaco.component'; + +import { createFormGroupDirectiveMock, WYSIWYG_MOCK } from '../../../../utils/mocks'; +import { + DEFAULT_MONACO_LANGUAGE, + DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG +} from '../../dot-edit-content-wysiwyg-field.constant'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).monaco = monacoMock; + +describe('DotWysiwygMonacoComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotWysiwygMonacoComponent, + imports: [MonacoEditorModule], + componentViewProviders: [ + { + provide: ControlContainer, + useValue: createFormGroupDirectiveMock() + } + ] + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + field: WYSIWYG_MOCK + } as unknown + }); + }); + + it('should set default language', () => { + expect(spectator.component.$language()).toBe(DEFAULT_MONACO_LANGUAGE); + }); + + it('should set custom language', () => { + const customLanguage = 'javascript'; + spectator.setInput('language', customLanguage); + expect(spectator.component.$language()).toBe(customLanguage); + }); + + it('should generate correct Monaco options', () => { + const expectedOptions = { + ...DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG, + language: DEFAULT_MONACO_LANGUAGE + }; + expect(spectator.component.$monacoOptions()).toEqual(expectedOptions); + }); + + it('should parse custom props from field variables', () => { + const customProps = { theme: 'vs-dark' }; + const fieldWithVariables = { + ...WYSIWYG_MOCK, + fieldVariables: [ + { + key: 'monacoOptions', + value: JSON.stringify(customProps) + } + ] + }; + spectator.setInput('field', fieldWithVariables); + + const expectedOptions = { + ...DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG, + ...customProps, + language: DEFAULT_MONACO_LANGUAGE + }; + expect(spectator.component.$monacoOptions()).toEqual(expectedOptions); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts new file mode 100644 index 00000000000..d9302f0405f --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component.ts @@ -0,0 +1,112 @@ +import { MonacoEditorComponent, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; + +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + OnDestroy, + viewChild +} from '@angular/core'; +import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; + +import { PaginatorModule } from 'primeng/paginator'; + +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +import { getFieldVariablesParsed, stringToJson } from '../../../../utils/functions.util'; +import { + DEFAULT_MONACO_LANGUAGE, + DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG +} from '../../dot-edit-content-wysiwyg-field.constant'; + +@Component({ + selector: 'dot-wysiwyg-monaco', + standalone: true, + imports: [MonacoEditorModule, PaginatorModule, ReactiveFormsModule], + templateUrl: './dot-wysiwyg-monaco.component.html', + styleUrl: './dot-wysiwyg-monaco.component.scss', + viewProviders: [ + { + provide: ControlContainer, + useFactory: () => inject(ControlContainer, { skipSelf: true }) + } + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotWysiwygMonacoComponent implements OnDestroy { + /** + * Holds a reference to the MonacoEditorComponent. + */ + $editorRef = viewChild('editorRef'); + + /** + * Represents a required DotCMS content type field. + */ + $field = input.required({ alias: 'field' }); + + /** + * Represents the programming language to be used in the Monaco editor. + * This variable sets the default language for code input and is initially set to `DEFAULT_MONACO_LANGUAGE`. + * It can be customized by providing a different value through the alias 'language'. + */ + $language = input(DEFAULT_MONACO_LANGUAGE, { alias: 'language' }); + + /** + * A computed property that retrieves and parses custom Monaco properties that comes from + * Field Variable with the name `monacoOptions` + * + */ + $customPropsContentField = computed(() => { + const { fieldVariables } = this.$field(); + const { monacoOptions } = getFieldVariablesParsed<{ monacoOptions: string }>( + fieldVariables + ); + + return stringToJson(monacoOptions); + }); + + /** + * Represents an instance of the Monaco Code Editor. + */ + #editor: monaco.editor.IStandaloneCodeEditor = null; + + /** + * A computed property that generates the configuration options for the Monaco editor. + * + * This property merges default Monaco editor configurations with custom ones and sets the editor's language. + * + */ + $monacoOptions = computed(() => { + return { + ...DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG, + ...this.$customPropsContentField(), + language: this.$language() + }; + }); + + onEditorInit() { + this.#editor = this.$editorRef().editor; + } + + ngOnDestroy() { + try { + if (this.#editor) { + this.removeEditor(); + } + + const model = this.#editor?.getModel(); + if (model && !model.isDisposed()) { + model.dispose(); + } + } catch (error) { + console.error('Error during Monaco Editor cleanup:', error); + } + } + + private removeEditor() { + this.#editor.dispose(); + this.#editor = null; + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.html new file mode 100644 index 00000000000..4d9a965cd27 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.html @@ -0,0 +1,4 @@ + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.spec.ts new file mode 100644 index 00000000000..57aa9790ca1 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.spec.ts @@ -0,0 +1,144 @@ +import { jest } from '@jest/globals'; +import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@ngneat/spectator/jest'; +import { BehaviorSubject, of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ControlContainer, FormGroupDirective } from '@angular/forms'; + +import { DotUploadFileService } from '@dotcms/data-access'; + +import { DotWysiwygTinymceComponent } from './dot-wysiwyg-tinymce.component'; +import { DotWysiwygTinymceService } from './service/dot-wysiwyg-tinymce.service'; + +import { createFormGroupDirectiveMock, WYSIWYG_MOCK } from '../../../../utils/mocks'; +import { DEFAULT_TINYMCE_CONFIG } from '../../dot-edit-content-wysiwyg-field.constant'; +import { DotWysiwygPluginService } from '../../dot-wysiwyg-plugin/dot-wysiwyg-plugin.service'; + +const mockSystemWideConfig = { systemWideOption: 'value' }; + +describe('DotWysiwygTinymceComponent', () => { + let spectator: Spectator; + let dotWysiwygPluginService: DotWysiwygPluginService; + let dotWysiwygTinymceService: SpyObject; + + const createComponent = createComponentFactory({ + component: DotWysiwygTinymceComponent, + componentViewProviders: [ + { + provide: ControlContainer, + useValue: createFormGroupDirectiveMock() + }, + mockProvider(DotWysiwygTinymceService), + + mockProvider(DotWysiwygTinymceService, { + getProps: jest.fn().mockReturnValue(of(mockSystemWideConfig)) + }) + ], + providers: [ + FormGroupDirective, + provideHttpClient(), + provideHttpClientTesting(), + mockProvider(DotUploadFileService) + ] + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + field: WYSIWYG_MOCK + } as unknown, + detectChanges: false + }); + + dotWysiwygPluginService = spectator.inject(DotWysiwygPluginService, true); + dotWysiwygTinymceService = spectator.inject(DotWysiwygTinymceService, true); + }); + + it('should initialize editor with correct configuration', fakeAsync(() => { + const expectedConfiguration = { + ...DEFAULT_TINYMCE_CONFIG, + ...mockSystemWideConfig, + setup: (editor) => dotWysiwygPluginService.initializePlugins(editor) + }; + + spectator.detectChanges(); + + expect(JSON.stringify(spectator.component.$editorConfig())).toEqual( + JSON.stringify(expectedConfiguration) + ); + })); + + it('should parse custom props from field variables', () => { + const fieldVariables = [ + { + clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', + fieldId: '1', + id: '1', + key: 'tinymceprops', + value: '{ "toolbar1": "undo redo"}' + } + ]; + + spectator = createComponent({ + props: { + field: { + ...WYSIWYG_MOCK, + fieldVariables + } + } as unknown, + detectChanges: false + }); + + spectator.detectChanges(); + + expect(JSON.stringify(spectator.component.$editorConfig())).toEqual( + JSON.stringify({ + ...DEFAULT_TINYMCE_CONFIG, + ...mockSystemWideConfig, + ...{ toolbar1: 'undo redo' }, + setup: (editor) => dotWysiwygPluginService.initializePlugins(editor) + }) + ); + }); + + it('should set the system wide props', fakeAsync(() => { + const newSystemWideConfig = { systemWideOption: 'new_value' }; + + const propsSubject = new BehaviorSubject(mockSystemWideConfig); + + dotWysiwygTinymceService.getProps.mockReturnValue(propsSubject); + + spectator = createComponent({ + props: { + field: WYSIWYG_MOCK + } as unknown, + detectChanges: false + }); + + spectator.detectChanges(); + + tick(100); + + expect(JSON.stringify(spectator.component.$editorConfig())).toEqual( + JSON.stringify({ + ...DEFAULT_TINYMCE_CONFIG, + ...mockSystemWideConfig, + setup: (editor) => dotWysiwygPluginService.initializePlugins(editor) + }) + ); + + propsSubject.next(newSystemWideConfig); + tick(0); + spectator.detectChanges(); + + expect(JSON.stringify(spectator.component.$editorConfig())).toEqual( + JSON.stringify({ + ...DEFAULT_TINYMCE_CONFIG, + ...newSystemWideConfig, + setup: (editor) => dotWysiwygPluginService.initializePlugins(editor) + }) + ); + })); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts new file mode 100644 index 00000000000..5f226a4d1ce --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component.ts @@ -0,0 +1,109 @@ +import { EditorComponent, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular'; +import { Editor, RawEditorOptions } from 'tinymce'; + +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + OnDestroy +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +import { DotWysiwygTinymceService } from './service/dot-wysiwyg-tinymce.service'; + +import { getFieldVariablesParsed, stringToJson } from '../../../../utils/functions.util'; +import { DEFAULT_TINYMCE_CONFIG } from '../../dot-edit-content-wysiwyg-field.constant'; +import { DotWysiwygPluginService } from '../../dot-wysiwyg-plugin/dot-wysiwyg-plugin.service'; + +@Component({ + selector: 'dot-wysiwyg-tinymce', + standalone: true, + imports: [EditorComponent, ReactiveFormsModule], + templateUrl: './dot-wysiwyg-tinymce.component.html', + styleUrl: './dot-wysiwyg-tinymce.component.scss', + viewProviders: [ + { + provide: ControlContainer, + useFactory: () => inject(ControlContainer, { skipSelf: true }) + } + ], + providers: [ + DialogService, + DotWysiwygTinymceService, + DotWysiwygPluginService, + { provide: TINYMCE_SCRIPT_SRC, useValue: 'tinymce/tinymce.min.js' } + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotWysiwygTinymceComponent implements OnDestroy { + #dotWysiwygPluginService = inject(DotWysiwygPluginService); + #dotWysiwygTinymceService = inject(DotWysiwygTinymceService); + + /** + * Represents a required DotCMS content type field. + */ + $field = input.required({ alias: 'field' }); + + /** + * A computed property that retrieves and parses custom TinyMCE properties that comes from + * Field Variable with the name `tinymceprops` + * + */ + $customPropsContentField = computed(() => { + const { fieldVariables } = this.$field(); + const { tinymceprops } = getFieldVariablesParsed(fieldVariables); + + return stringToJson(tinymceprops as string); + }); + + /** + * Represents a signal that contains the wide configuration properties for the TinyMCE WYSIWYG editor. + */ + $wideConfig = toSignal(this.#dotWysiwygTinymceService.getProps()); + + /** + * A computed property that generates the configuration object for the TinyMCE editor. + * This configuration merges default settings, wide configuration settings, + * and custom properties specific to the content field. Additionally, it sets + * up the editor with initial plugins using the `dotWysiwygPluginService`. + */ + + $editorConfig = computed(() => { + return { + ...DEFAULT_TINYMCE_CONFIG, + ...(this.$wideConfig() || {}), + ...this.$customPropsContentField(), + setup: (editor) => this.#dotWysiwygPluginService.initializePlugins(editor) + }; + }); + + /** + * The #editor variable represents an instance of the Editor class, which provides functionality for text editing. + */ + #editor: Editor = null; + + /** + * Handles the initialization of the editor instance. + */ + handleEditorInit(event: { editor: Editor }): void { + this.#editor = event.editor; + } + + ngOnDestroy(): void { + if (this.#editor) { + this.removeEditor(); + } + } + + private removeEditor(): void { + this.#editor.remove(); + this.#editor = null; + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service.spec.ts new file mode 100644 index 00000000000..be2aab475a0 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service.spec.ts @@ -0,0 +1,27 @@ +import { HttpMethod } from '@ngneat/spectator'; +import { createHttpFactory, SpectatorHttp } from '@ngneat/spectator/jest'; + +import { CONFIG_PATH, DotWysiwygTinymceService } from './dot-wysiwyg-tinymce.service'; + +describe('DotWysiwygTinyceService', () => { + let spectator: SpectatorHttp; + const createHttp = createHttpFactory(DotWysiwygTinymceService); + + beforeEach(() => (spectator = createHttp())); + + it('should do the configuration healthcheck', () => { + spectator.service.getProps().subscribe(); + spectator.expectOne(`${CONFIG_PATH}/tinymceprops`, HttpMethod.GET); + }); + + it('should return null if HTTP request fails with status 400', () => { + spectator.service.getProps().subscribe((response) => { + expect(response).toBeNull(); + }); + + spectator.expectOne(`${CONFIG_PATH}/tinymceprops`, HttpMethod.GET).flush(null, { + status: 400, + statusText: 'Bad Request' + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service.ts new file mode 100644 index 00000000000..b3690e7d9ca --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service.ts @@ -0,0 +1,20 @@ +import { of } from 'rxjs'; +import { RawEditorOptions } from 'tinymce'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { catchError } from 'rxjs/operators'; + +export const CONFIG_PATH = '/api/vtl'; + +@Injectable() +export class DotWysiwygTinymceService { + #http = inject(HttpClient); + + getProps() { + return this.#http + .get(`${CONFIG_PATH}/tinymceprops`) + .pipe(catchError(() => of(null))); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html index 51c32d010e2..b65c81ec23c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html @@ -1,7 +1,22 @@ - -@if (init()) { - -} +
+ @if ($selectedEditor() === editorTypes.TinyMCE) { + + } @else { + + } +
+
+ + + @if ($selectedEditor() === editorTypes.Monaco) { + + } +
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss index a61ef6345a1..26867d40f9b 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss @@ -1,21 +1,43 @@ @use "variables" as *; - -:host::ng-deep { - // Hide the promotion button - // This button redirect to the tinyMCE premium page - .tox-promotion { - display: none; +:host { + &.wysiwyg__wrapper { + display: flex; + gap: $spacing-1; + flex-direction: column; } - .tox-statusbar__branding { - display: none; + .wysiwyg__editor { + display: flex; + flex-direction: column; } - .tox .tox-statusbar { - border-top: 1px solid $color-palette-gray-400; + .wysiwyg__controls { + display: flex; + justify-content: space-between; } - .tox-tinymce { - border: $field-border-size solid $color-palette-gray-400; + ::ng-deep { + // Hide the promotion button + // This button redirect to the tinyMCE premium page + .tox-promotion { + display: none; + } + + .tox-statusbar__branding { + display: none; + } + + .tox .tox-statusbar { + border-top: 1px solid $color-palette-gray-400; + } + + .tox-tinymce { + border: $field-border-size solid $color-palette-gray-400; + border-radius: $border-radius-md; + } + + p-dropdown { + min-width: 10rem; + } } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts index ce3b3500a92..f4916eaa8a4 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts @@ -1,53 +1,46 @@ -import { expect } from '@jest/globals'; -import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; -import { EditorComponent, EditorModule } from '@tinymce/tinymce-angular'; -import { MockComponent, MockService } from 'ng-mocks'; -import { of, throwError } from 'rxjs'; -import { Editor } from 'tinymce'; - -import { HttpClient } from '@angular/common/http'; -import { - ControlContainer, - FormGroupDirective, - FormsModule, - ReactiveFormsModule -} from '@angular/forms'; +import { Spectator, createComponentFactory, byTestId, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ControlContainer, FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { DialogService } from 'primeng/dynamicdialog'; +import { DropdownModule } from 'primeng/dropdown'; -import { DotUploadFileService } from '@dotcms/data-access'; +import { DotPropertiesService, DotUploadFileService } from '@dotcms/data-access'; +import { mockMatchMedia } from '@dotcms/utils-testing'; +import { DotWysiwygMonacoComponent } from './components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component'; +import { DotWysiwygTinymceComponent } from './components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component'; +import { DotWysiwygTinymceService } from './components/dot-wysiwyg-tinymce/service/dot-wysiwyg-tinymce.service'; import { DotEditContentWYSIWYGFieldComponent } from './dot-edit-content-wysiwyg-field.component'; +import { + AvailableEditor, + DEFAULT_EDITOR, + EditorOptions +} from './dot-edit-content-wysiwyg-field.constant'; import { DotWysiwygPluginService } from './dot-wysiwyg-plugin/dot-wysiwyg-plugin.service'; +import { DEFAULT_IMAGE_URL_PATTERN } from './dot-wysiwyg-plugin/utils/editor.utils'; + +import { createFormGroupDirectiveMock, WYSIWYG_MOCK } from '../../utils/mocks'; -import { WYSIWYG_MOCK, createFormGroupDirectiveMock } from '../../utils/mocks'; - -const DEFAULT_CONFIG = { - menubar: false, - image_caption: true, - image_advtab: true, - contextmenu: 'align link image', - toolbar1: - 'undo redo | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent dotAddImage hr', - plugins: - 'advlist autolink lists link image charmap preview anchor pagebreak searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking save table directionality emoticons template' +const mockScrollIntoView = () => { + Element.prototype.scrollIntoView = jest.fn(); }; +const mockSystemWideConfig = { systemWideOption: 'value' }; + describe('DotEditContentWYSIWYGFieldComponent', () => { let spectator: Spectator; - let dotWysiwygPluginService: DotWysiwygPluginService; - let httpClient: HttpClient; const createComponent = createComponentFactory({ component: DotEditContentWYSIWYGFieldComponent, - imports: [EditorModule, FormsModule, ReactiveFormsModule], - declarations: [MockComponent(EditorComponent)], + imports: [DropdownModule, NoopAnimationsModule, FormsModule], componentViewProviders: [ { - provide: HttpClient, - useValue: { - get: () => of(null) - } + provide: ControlContainer, + useValue: createFormGroupDirectiveMock() }, { provide: DotWysiwygPluginService, @@ -55,166 +48,76 @@ describe('DotEditContentWYSIWYGFieldComponent', () => { initializePlugins: jest.fn() } }, - { - provide: ControlContainer, - useValue: createFormGroupDirectiveMock() - } + mockProvider(DotWysiwygTinymceService, { + getProps: () => of(mockSystemWideConfig) + }), + mockProvider(DotPropertiesService, { + getKey: () => of(DEFAULT_IMAGE_URL_PATTERN) + }) ], providers: [ - FormGroupDirective, - DialogService, - { - provide: DotUploadFileService, - useValue: MockService(DotUploadFileService) - } + mockProvider(DotUploadFileService), + provideHttpClient(), + provideHttpClientTesting() ] }); beforeEach(() => { + // Needed for Dropdown PrimeNG to simulate a click and the overlay + mockMatchMedia(); + mockScrollIntoView(); + // end spectator = createComponent({ props: { field: WYSIWYG_MOCK - }, + } as unknown, detectChanges: false }); - dotWysiwygPluginService = spectator.inject(DotWysiwygPluginService, true); - httpClient = spectator.inject(HttpClient, true); - }); - - it('should instance WYSIWYG editor and set the correct configuration', () => { spectator.detectChanges(); - const editor = spectator.query(EditorComponent); - expect(editor.init).toEqual({ - ...DEFAULT_CONFIG, - theme: 'silver', - setup: expect.any(Function) - }); - }); - - it('should initialize Plugins when the setup method is called', () => { - spectator.detectChanges(); - const spy = jest.spyOn(dotWysiwygPluginService, 'initializePlugins'); - const editor = spectator.query(EditorComponent); - const mockEditor = {} as Editor; - editor.init.setup(mockEditor); - expect(spy).toHaveBeenCalledWith(mockEditor); }); - describe('variables', () => { - it('should overwrite the editor configuration with the field variables', () => { - const fieldVariables = [ - { - clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', - fieldId: '1', - id: '1', - key: 'tinymceprops', - value: '{ "toolbar1": "undo redo"}' - } - ]; - - spectator.setInput('field', { - ...WYSIWYG_MOCK, - fieldVariables - }); - - const editor = spectator.query(EditorComponent); - expect(editor.init).toEqual({ - ...DEFAULT_CONFIG, - theme: 'silver', - toolbar1: 'undo redo', - setup: expect.any(Function) - }); - }); + describe('UI', () => { + it('should render TinyMCE as default editor', () => { + expect(DEFAULT_EDITOR).toBe(AvailableEditor.TinyMCE); - it('should not configure theme property', () => { - const fieldVariables = [ - { - clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', - fieldId: '1', - id: '1', - key: 'tinymceprops', - value: '{theme: "modern"}' - } - ]; - - spectator.setInput('field', { - ...WYSIWYG_MOCK, - fieldVariables - }); - - const editor = spectator.query(EditorComponent); - expect(editor.init).toEqual({ - ...DEFAULT_CONFIG, - theme: 'silver', - setup: expect.any(Function) - }); + expect(spectator.query(DotWysiwygTinymceComponent)).toBeTruthy(); + expect(spectator.query(DotWysiwygMonacoComponent)).toBeNull(); }); - }); - describe('Systemwide TinyMCE prop', () => { - it('should set the systemwide TinyMCE props', () => { - const SYSTEM_WIDE_CONFIG = { - toolbar1: 'undo redo | bold italic', - theme: 'modern' - }; - - jest.spyOn(httpClient, 'get').mockReturnValue(of(SYSTEM_WIDE_CONFIG)); + it('should render editor selection dropdown', () => { + expect(spectator.query(byTestId('editor-selector'))).toBeTruthy(); + // Open dropdown + const dropdownTrigger = spectator.query('.p-dropdown-trigger'); + spectator.click(dropdownTrigger); spectator.detectChanges(); - const editor = spectator.query(EditorComponent); - expect(editor.init).toEqual({ - ...SYSTEM_WIDE_CONFIG, - theme: 'silver', - setup: expect.any(Function) - }); + expect(spectator.queryAll('.p-dropdown-item').length).toBe(EditorOptions.length); }); - it('should set default values if the systemwide TinyMCE props throws an error', () => { - jest.spyOn(httpClient, 'get').mockReturnValue(throwError(null)); + it('should render editor selection dropdown and switch to Monaco editor when selected', () => { + expect(spectator.query(DotWysiwygTinymceComponent)).toBeTruthy(); + expect(spectator.query(DotWysiwygMonacoComponent)).toBeNull(); + spectator.component.$selectedEditor.set(AvailableEditor.Monaco); spectator.detectChanges(); - const editor = spectator.query(EditorComponent); - expect(editor.init).toEqual({ - ...DEFAULT_CONFIG, - theme: 'silver', - setup: expect.any(Function) - }); + expect(spectator.query(DotWysiwygTinymceComponent)).toBeNull(); + expect(spectator.query(DotWysiwygMonacoComponent)).toBeTruthy(); }); + }); - it('should overwrite the systemwide TinyMCE props with the field variables', () => { - const SYSTEM_WIDE_CONFIG = { - toolbar1: 'undo redo | bold italic' - }; - - const fieldVariables = [ - { - clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', - fieldId: '1', - id: '1', - key: 'tinymceprops', - value: '{ "toolbar1" : "undo redo" }' - } - ]; - - jest.spyOn(httpClient, 'get').mockReturnValue(of(SYSTEM_WIDE_CONFIG)); - - spectator.setInput('field', { - ...WYSIWYG_MOCK, - fieldVariables - }); - + // describe('TinyMCE Editor', () => {}); + describe('With Monaco Editor', () => { + beforeEach(() => { + spectator.component.$selectedEditor.set(AvailableEditor.Monaco); spectator.detectChanges(); + }); - const editor = spectator.query(EditorComponent); - expect(editor.init).toEqual({ - ...SYSTEM_WIDE_CONFIG, - theme: 'silver', - toolbar1: 'undo redo', - setup: expect.any(Function) - }); + it('should has a dropdown for language selection', () => { + expect(spectator.query(byTestId('language-selector'))).toBeTruthy(); + expect(spectator.query(byTestId('editor-selector'))).toBeTruthy(); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts index 8cd1f9400bf..43e5d6a4af8 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts @@ -1,76 +1,56 @@ -import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular'; -import { of } from 'rxjs'; -import { RawEditorOptions } from 'tinymce'; +import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; -import { HttpClient, HttpClientModule } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, Input, OnInit, inject, signal } from '@angular/core'; -import { ControlContainer, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; -import { DialogService } from 'primeng/dynamicdialog'; - -import { catchError } from 'rxjs/operators'; +import { DropdownModule } from 'primeng/dropdown'; import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; -import { DotWysiwygPluginService } from './dot-wysiwyg-plugin/dot-wysiwyg-plugin.service'; - -import { getFieldVariablesParsed, stringToJson } from '../../utils/functions.util'; - -const DEFAULT_CONFIG = { - menubar: false, - image_caption: true, - image_advtab: true, - contextmenu: 'align link image', - toolbar1: - 'undo redo | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent dotAddImage hr', - plugins: - 'advlist autolink lists link image charmap preview anchor pagebreak searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking save table directionality emoticons template' -}; +import { DotWysiwygMonacoComponent } from './components/dot-wysiwyg-monaco/dot-wysiwyg-monaco.component'; +import { DotWysiwygTinymceComponent } from './components/dot-wysiwyg-tinymce/dot-wysiwyg-tinymce.component'; +import { + AvailableEditor, + DEFAULT_EDITOR, + DEFAULT_MONACO_LANGUAGE, + EditorOptions, + MonacoLanguageOptions +} from './dot-edit-content-wysiwyg-field.constant'; @Component({ selector: 'dot-edit-content-wysiwyg-field', standalone: true, - imports: [EditorModule, FormsModule, ReactiveFormsModule, HttpClientModule], + imports: [ + FormsModule, + DropdownModule, + DotWysiwygTinymceComponent, + DotWysiwygMonacoComponent, + MonacoEditorModule + ], templateUrl: './dot-edit-content-wysiwyg-field.component.html', styleUrl: './dot-edit-content-wysiwyg-field.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - HttpClient, - DialogService, - DotWysiwygPluginService, - { provide: TINYMCE_SCRIPT_SRC, useValue: 'tinymce/tinymce.min.js' } - ], - viewProviders: [ - { - provide: ControlContainer, - useFactory: () => inject(ControlContainer, { skipSelf: true }) - } - ] + host: { + class: 'wysiwyg__wrapper' + }, + changeDetection: ChangeDetectionStrategy.OnPush }) -export class DotEditContentWYSIWYGFieldComponent implements OnInit { - @Input() field!: DotCMSContentTypeField; - - private readonly http = inject(HttpClient); - private readonly dotWysiwygPluginService = inject(DotWysiwygPluginService); - - private readonly configPath = '/api/vtl/tinymceprops'; - protected readonly init = signal(null); - - ngOnInit(): void { - const { tinymceprops } = getFieldVariablesParsed(this.field.fieldVariables); - const variables = stringToJson(tinymceprops as string); - - this.http - .get(this.configPath) - .pipe(catchError(() => of(null))) - .subscribe((SYTEM_WIDE_CONFIG) => { - const CONFIG = SYTEM_WIDE_CONFIG || DEFAULT_CONFIG; - this.init.set({ - setup: (editor) => this.dotWysiwygPluginService.initializePlugins(editor), - ...CONFIG, - ...variables, - theme: 'silver' // In the new version, there is only one theme, which is the default one. Docs: https://www.tiny.cloud/docs/tinymce/latest/editor-theme/ - }); - }); - } +export class DotEditContentWYSIWYGFieldComponent { + /** + * This variable represents a required content type field in DotCMS. + */ + $field = input({} as DotCMSContentTypeField, { alias: 'field' }); + + /** + * A variable representing the editor selected by the user. + */ + $selectedEditor = signal(DEFAULT_EDITOR); + + /** + * A variable representing the currently selected language. + */ + $selectedLanguage = signal(DEFAULT_MONACO_LANGUAGE); + + readonly editorTypes = AvailableEditor; + readonly editorOptions = EditorOptions; + readonly monacoLanguagesOptions = MonacoLanguageOptions; } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts new file mode 100644 index 00000000000..30f3f47be89 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.constant.ts @@ -0,0 +1,48 @@ +import { MonacoEditorConstructionOptions } from '@materia-ui/ngx-monaco-editor'; + +import { SelectItem } from 'primeng/api'; + +import { DEFAULT_MONACO_CONFIG } from '../../models/dot-edit-content-field.constant'; + +// Available Editors to switch +export enum AvailableEditor { + TinyMCE = 'TinyMCE', + Monaco = 'Monaco' +} + +// Dropdown values to use in Monaco Editor +export const MonacoLanguageOptions: SelectItem[] = [ + { label: 'Plain Text', value: 'plaintext' }, + { label: 'TypeScript', value: 'typescript' }, + { label: 'HTML', value: 'html' }, + { label: 'Markdown', value: 'markdown' } +]; + +// Dropdown values to select Editors +export const EditorOptions: SelectItem[] = [ + { label: 'WYSIWYG', value: AvailableEditor.TinyMCE }, + { label: 'Code', value: AvailableEditor.Monaco } +]; + +export const DEFAULT_EDITOR = AvailableEditor.TinyMCE; + +export const DEFAULT_MONACO_LANGUAGE = 'html'; + +export const DEFAULT_WYSIWYG_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = { + ...DEFAULT_MONACO_CONFIG, + language: DEFAULT_MONACO_LANGUAGE, + automaticLayout: true, + theme: 'vs' +}; + +export const DEFAULT_TINYMCE_CONFIG = { + menubar: false, + image_caption: true, + image_advtab: true, + contextmenu: 'align link image', + toolbar1: + 'undo redo | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent dotAddImage hr', + plugins: + 'advlist autolink lists link image charmap preview anchor pagebreak searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media nonbreaking save table directionality emoticons template', + theme: 'silver' +}; diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts index 8ac24de4005..2f071ef1102 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts @@ -10,6 +10,8 @@ export enum DotEditContentFieldSingleSelectableDataType { // Map to match the field type to component selector export enum FIELD_TYPES { BINARY = 'Binary', + FILE = 'File', + IMAGE = 'Image', BLOCK_EDITOR = 'Story-Block', CATEGORY = 'Category', CHECKBOX = 'Checkbox', diff --git a/core-web/libs/edit-content/src/lib/utils/mocks.ts b/core-web/libs/edit-content/src/lib/utils/mocks.ts index 91951ff8199..8881867bbe5 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -486,6 +486,54 @@ export const BINARY_FIELD_MOCK: DotCMSContentTypeField = { variable: 'binaryField' }; +export const IMAGE_FIELD_MOCK: DotCMSContentTypeField = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableImageField', + contentTypeId: 'a8f941d835e4b4f3e4e71b45add34c60', + dataType: 'TEXT', + fieldType: 'Image', + fieldTypeLabel: 'Image', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1726517012000, + id: 'fec3e11696cf9b0f99139c160a598e02', + indexed: false, + listed: false, + modDate: 1726517012000, + name: 'Image Field', + readOnly: false, + required: false, + searchable: false, + sortOrder: 2, + unique: false, + variable: 'imageField', + hint: 'Helper label to be displayed below the field' +}; + +export const FILE_FIELD_MOCK: DotCMSContentTypeField = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableFileField', + contentTypeId: 'a8f941d835e4b4f3e4e71b45add34c60', + dataType: 'TEXT', + fieldType: 'File', + fieldTypeLabel: 'File', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1726507692000, + id: 'f90afb1384e04507ba03e8701f7e4000', + indexed: false, + listed: false, + modDate: 1726517016000, + name: 'File Field', + readOnly: false, + required: false, + searchable: false, + sortOrder: 3, + unique: false, + variable: 'file1', + hint: 'Helper label to be displayed below the field' +}; + export const CUSTOM_FIELD_MOCK: DotCMSContentTypeField = { clazz: 'com.dotcms.contenttype.model.field.ImmutableCustomField', contentTypeId: '61226fd915b7f025da020fc1f5856ab7', @@ -698,6 +746,8 @@ export const FIELDS_MOCK: DotCMSContentTypeField[] = [ MULTI_SELECT_FIELD_MOCK, BLOCK_EDITOR_FIELD_MOCK, BINARY_FIELD_MOCK, + FILE_FIELD_MOCK, + IMAGE_FIELD_MOCK, CUSTOM_FIELD_MOCK, JSON_FIELD_MOCK, KEY_VALUE_MOCK, diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts index eb9981516db..542690f5a24 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts @@ -133,6 +133,11 @@ describe('DotExperimentsConfigurationComponent', () => { jest.spyOn(ConfirmPopup.prototype, 'bindScrollListener').mockImplementation(jest.fn()); }); + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + it('should show the skeleton component when is loading', () => { spectator.component.vm$ = of({ ...defaultVmMock, diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.html index 8efd156b8f3..dd75db6e3c7 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.html @@ -1,5 +1,4 @@ + (visibleChange)="onHide()"> @switch (ds.type) { @case ('form') { { @@ -178,7 +178,11 @@ describe('DotEmaDialogComponent', () => { event: expect.objectContaining({ isTrusted: false }), - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + form: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); }); @@ -188,7 +192,7 @@ describe('DotEmaDialogComponent', () => { component.addContentlet(PAYLOAD_MOCK); // This is to make the dialog open spectator.detectChanges(); - spectator.triggerEventHandler(Dialog, 'onHide', {}); + spectator.triggerEventHandler(Dialog, 'visibleChange', false); expect(actionSpy).toHaveBeenCalledWith({ event: new CustomEvent('ng-event', { @@ -196,7 +200,11 @@ describe('DotEmaDialogComponent', () => { name: NG_CUSTOM_EVENTS.DIALOG_CLOSED } }), - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + form: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); }); }); @@ -219,6 +227,83 @@ describe('DotEmaDialogComponent', () => { expect(handleWorkflowEventSpy).toHaveBeenCalledWith({}); }); + it("should trigger setDirty in the store when the iframe's custom event is 'edit-contentlet-data-updated' and is not a translation", () => { + const setDirtySpy = jest.spyOn(storeSpy, 'setDirty'); + + component.addContentlet(PAYLOAD_MOCK); // This is to make the dialog open + spectator.detectChanges(); + + triggerIframeCustomEvent({ + detail: { + name: NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED, + data: {}, + payload: {} + } + }); + + expect(setDirtySpy).toHaveBeenCalled(); + }); + + it("should trigger setSaved in the store when the iframe's custom event is 'edit-contentlet-data-updated' and is a translation", () => { + const setSavedSpy = jest.spyOn(storeSpy, 'setSaved'); + + component.translatePage({ + page: MOCK_RESPONSE_HEADLESS.page, + newLanguage: '3' + }); // This is to make the dialog open + spectator.detectChanges(); + + triggerIframeCustomEvent({ + detail: { + name: NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED, + data: {}, + payload: {} + } + }); + + expect(setSavedSpy).toHaveBeenCalled(); + }); + + it("should trigger setSaved in the store when the iframe's custom event is 'edit-contentlet-data-updated', is a translation and payload is move action", () => { + const reloadIframeSpy = jest.spyOn(component, 'reloadIframe'); + + component.translatePage({ + page: MOCK_RESPONSE_HEADLESS.page, + newLanguage: '3' + }); // This is to make the dialog open + spectator.detectChanges(); + + triggerIframeCustomEvent({ + detail: { + name: NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED, + data: {}, + payload: { + isMoveAction: true + } + } + }); + + expect(reloadIframeSpy).toHaveBeenCalled(); + }); + + it("should trigger setSaved when the iframe's custom event is 'save-page'", () => { + const setSavedSpy = jest.spyOn(storeSpy, 'setSaved'); + + component.addContentlet(PAYLOAD_MOCK); // This is to make the dialog open + spectator.detectChanges(); + + triggerIframeCustomEvent({ + detail: { + name: NG_CUSTOM_EVENTS.SAVE_PAGE, + payload: { + isMoveAction: false + } + } + }); + + expect(setSavedSpy).toHaveBeenCalled(); + }); + it("should reload the iframe when the iframe's custom event is 'save-page' and the payload is a move action", () => { const reloadIframeSpy = jest.spyOn(component, 'reloadIframe'); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts index 930c7336045..3ddbe62b695 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts @@ -9,6 +9,7 @@ import { NgZone, Output, ViewChild, + computed, inject, signal } from '@angular/core'; @@ -29,16 +30,18 @@ import { import { DotContentCompareModule } from '@dotcms/portlets/dot-ema/ui'; import { DotSpinnerModule, SafeUrlPipe } from '@dotcms/ui'; +import { DotEmaDialogStore } from './store/dot-ema-dialog.store'; + +import { DotEmaWorkflowActionsService } from '../../services/dot-ema-workflow-actions/dot-ema-workflow-actions.service'; +import { DialogStatus, NG_CUSTOM_EVENTS } from '../../shared/enums'; import { + ActionPayload, CreateContentletAction, CreateFromPaletteAction, - DialogStatus, - DotEmaDialogStore -} from './store/dot-ema-dialog.store'; - -import { DotEmaWorkflowActionsService } from '../../services/dot-ema-workflow-actions/dot-ema-workflow-actions.service'; -import { NG_CUSTOM_EVENTS } from '../../shared/enums'; -import { ActionPayload, DotPage, VTLFile } from '../../shared/models'; + DialogAction, + DotPage, + VTLFile +} from '../../shared/models'; import { EmaFormSelectorComponent } from '../ema-form-selector/ema-form-selector.component'; @Component({ @@ -58,11 +61,13 @@ import { EmaFormSelectorComponent } from '../ema-form-selector/ema-form-selector export class DotEmaDialogComponent { @ViewChild('iframe') iframe: ElementRef; - @Output() action = new EventEmitter<{ event: CustomEvent; payload: ActionPayload }>(); + @Output() action = new EventEmitter(); @Output() reloadFromDialog = new EventEmitter(); $compareData = signal(null); + $compareDataExists = computed(() => !!this.$compareData()); + private readonly destroyRef$ = inject(DestroyRef); private readonly store = inject(DotEmaDialogStore); private readonly workflowActions = inject(DotEmaWorkflowActionsService); @@ -283,14 +288,14 @@ export class DotEmaDialogComponent { } protected onHide() { - this.action.emit({ - event: new CustomEvent('ng-event', { - detail: { - name: NG_CUSTOM_EVENTS.DIALOG_CLOSED - } - }), - payload: this.dialogState().payload + const event = new CustomEvent('ng-event', { + detail: { + name: NG_CUSTOM_EVENTS.DIALOG_CLOSED + } }); + + this.emitAction(event); + this.resetDialog(); } /** @@ -310,21 +315,51 @@ export class DotEmaDialogComponent { ) .pipe(takeUntilDestroyed(this.destroyRef$)) .subscribe((event: CustomEvent) => { - this.action.emit({ event, payload: this.dialogState().payload }); - - if (event.detail.name === NG_CUSTOM_EVENTS.COMPARE_CONTENTLET) { - this.ngZone.run(() => { - this.$compareData.set(event.detail.data); - }); - } - - if (event.detail.name === NG_CUSTOM_EVENTS.OPEN_WIZARD) { - this.handleWorkflowEvent(event.detail.data); - } else if ( - event.detail.name === NG_CUSTOM_EVENTS.SAVE_PAGE && - event.detail.payload.isMoveAction - ) { - this.reloadIframe(); + this.emitAction(event); + + switch (event.detail.name) { + case NG_CUSTOM_EVENTS.DIALOG_CLOSED: { + this.store.resetDialog(); + + break; + } + + case NG_CUSTOM_EVENTS.COMPARE_CONTENTLET: { + this.ngZone.run(() => { + this.$compareData.set(event.detail.data); + }); + break; + } + + case NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED: { + // The edit content emits this for savings when translating a page and does not emit anything when changing the content + if (this.dialogState().editContentForm.isTranslation) { + this.store.setSaved(); + + if (event.detail.payload.isMoveAction) { + this.reloadIframe(); + } + } else { + this.store.setDirty(); + } + + break; + } + + case NG_CUSTOM_EVENTS.OPEN_WIZARD: { + this.handleWorkflowEvent(event.detail.data); + break; + } + + case NG_CUSTOM_EVENTS.SAVE_PAGE: { + this.store.setSaved(); + + if (event.detail.payload.isMoveAction) { + this.reloadIframe(); + } + + break; + } } }); } @@ -346,7 +381,7 @@ export class DotEmaDialogComponent { } }); - this.action.emit({ event: customEvent, payload: this.dialogState().payload }); + this.emitAction(customEvent); } /** @@ -368,4 +403,10 @@ export class DotEmaDialogComponent { closeCompareDialog() { this.$compareData.set(null); } + + private emitAction(event: CustomEvent) { + const { payload, editContentForm } = this.dialogState(); + + this.action.emit({ event, payload, form: editContentForm }); + } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts index ac6346b6ed3..e7d6d8868e9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts @@ -5,10 +5,11 @@ import { of } from 'rxjs'; import { DotMessageService } from '@dotcms/data-access'; import { MockDotMessageService } from '@dotcms/utils-testing'; -import { DialogStatus, DotEmaDialogStore } from './dot-ema-dialog.store'; +import { DotEmaDialogStore } from './dot-ema-dialog.store'; import { DotActionUrlService } from '../../../services/dot-action-url/dot-action-url.service'; import { LAYOUT_URL } from '../../../shared/consts'; +import { DialogStatus, FormStatus } from '../../../shared/enums'; import { PAYLOAD_MOCK } from '../../../shared/mocks'; import { DotPage } from '../../../shared/models'; @@ -42,12 +43,34 @@ describe('DotEmaDialogStoreService', () => { url: '', header: '', type: null, - status: DialogStatus.LOADING + status: DialogStatus.LOADING, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); }); + it("should set the form state to 'DIRTY'", (done) => { + spectator.service.setDirty(); + + spectator.service.dialogState$.subscribe((state) => { + expect(state.editContentForm.status).toBe(FormStatus.DIRTY); + done(); + }); + }); + + it("should set the form state to 'SAVED'", (done) => { + spectator.service.setSaved(); + + spectator.service.dialogState$.subscribe((state) => { + expect(state.editContentForm.status).toBe(FormStatus.SAVED); + done(); + }); + }); + it('should reset iframe properties', (done) => { spectator.service.setStatus(DialogStatus.LOADING); @@ -59,7 +82,11 @@ describe('DotEmaDialogStoreService', () => { status: DialogStatus.IDLE, header: '', type: null, - payload: undefined + payload: undefined, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -86,7 +113,11 @@ describe('DotEmaDialogStoreService', () => { url: LAYOUT_URL + '?' + queryParams.toString(), status: DialogStatus.LOADING, header: 'test', - type: 'content' + type: 'content', + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -113,7 +144,11 @@ describe('DotEmaDialogStoreService', () => { url: LAYOUT_URL + '?' + queryParams.toString() + '&isURLMap=true', status: DialogStatus.LOADING, header: 'test', - type: 'content' + type: 'content', + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -133,7 +168,11 @@ describe('DotEmaDialogStoreService', () => { header: 'Search Content', type: 'content', status: DialogStatus.LOADING, - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -148,7 +187,11 @@ describe('DotEmaDialogStoreService', () => { status: DialogStatus.LOADING, url: null, type: 'form', - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -167,7 +210,11 @@ describe('DotEmaDialogStoreService', () => { status: DialogStatus.LOADING, header: 'Create test', type: 'content', - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -222,7 +269,11 @@ describe('DotEmaDialogStoreService', () => { url: '', status: DialogStatus.LOADING, header: 'test', - type: 'content' + type: 'content', + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -239,7 +290,11 @@ describe('DotEmaDialogStoreService', () => { url: 'https://demo.dotcms.com/jsp.jsp', status: DialogStatus.LOADING, header: 'test', - type: 'content' + type: 'content', + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }); done(); }); @@ -279,7 +334,11 @@ describe('DotEmaDialogStoreService', () => { url: LAYOUT_URL + '?' + queryParams.toString(), status: DialogStatus.LOADING, header: 'test', - type: 'content' + type: 'content', + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: true + } }); }); }); @@ -319,7 +378,11 @@ describe('DotEmaDialogStoreService', () => { url: LAYOUT_URL + '?' + queryParams.toString(), status: DialogStatus.LOADING, header: 'test', - type: 'content' + type: 'content', + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: true + } }); }); }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts index c1e27ac6602..3c2353cd910 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts @@ -9,46 +9,29 @@ import { DotMessageService } from '@dotcms/data-access'; import { DotActionUrlService } from '../../../services/dot-action-url/dot-action-url.service'; import { LAYOUT_URL, CONTENTLET_SELECTOR_URL } from '../../../shared/consts'; -import { ActionPayload, DotPage } from '../../../shared/models'; - -type DialogType = 'content' | 'form' | 'widget' | null; - -export enum DialogStatus { - IDLE = 'IDLE', - LOADING = 'LOADING', - INIT = 'INIT' -} - -export interface EditEmaDialogState { - header: string; - status: DialogStatus; - url: string; - type: DialogType; - payload?: ActionPayload; -} - -// We can modify this if we add more events, for now I think is enough -export interface CreateFromPaletteAction { - variable: string; - name: string; - payload: ActionPayload; -} - -interface EditContentletPayload { - inode: string; - title: string; -} - -export interface CreateContentletAction { - url: string; - contentType: string; - payload: ActionPayload; -} +import { DialogStatus, FormStatus } from '../../../shared/enums'; +import { + ActionPayload, + CreateContentletAction, + CreateFromPaletteAction, + DotPage, + EditContentletPayload, + EditEmaDialogState +} from '../../../shared/models'; @Injectable() export class DotEmaDialogStore extends ComponentStore { constructor() { - super({ header: '', url: '', type: null, status: DialogStatus.IDLE }); + super({ + header: '', + url: '', + type: null, + status: DialogStatus.IDLE, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } + }); } private dotActionUrlService = inject(DotActionUrlService); @@ -184,7 +167,11 @@ export class DotEmaDialogStore extends ComponentStore { header: page.title, status: DialogStatus.LOADING, type: 'content', - url: this.createTranslatePageUrl(page, newLanguage) + url: this.createTranslatePageUrl(page, newLanguage), + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: true + } }; } ); @@ -215,6 +202,36 @@ export class DotEmaDialogStore extends ComponentStore { } ); + /** + * This method is called when the user make changes in the form + * + * @memberof DotEmaDialogStore + */ + readonly setDirty = this.updater((state) => { + return { + ...state, + editContentForm: { + ...state.editContentForm, + status: FormStatus.DIRTY + } + }; + }); + + /** + * This method is called when the user save the form + * + * @memberof DotEmaDialogStore + */ + readonly setSaved = this.updater((state) => { + return { + ...state, + editContentForm: { + ...state.editContentForm, + status: FormStatus.SAVED + } + }; + }); + /** * This method is called when the user clicks on the [+ add] button and selects form as content type * @@ -243,7 +260,11 @@ export class DotEmaDialogStore extends ComponentStore { header: '', status: DialogStatus.IDLE, type: null, - payload: undefined + payload: undefined, + editContentForm: { + status: FormStatus.PRISTINE, + isTranslation: false + } }; }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html index c5bfb3b94eb..d203f856139 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html @@ -1,5 +1,5 @@ @if ($shellProps()?.canRead) { - + { store = spectator.inject(UVEStore, true); router = spectator.inject(Router, true); confirmationService = spectator.inject(ConfirmationService, true); - jest.spyOn(store, 'load'); + jest.spyOn(store, 'init'); confirmationServiceSpy = jest.spyOn(confirmationService, 'confirm'); spectator.triggerNavigation({ @@ -279,7 +279,7 @@ describe('DotEmaShellComponent', () => { it('should trigger an store load with default values', () => { spectator.detectChanges(); - expect(store.load).toHaveBeenCalledWith({ + expect(store.init).toHaveBeenCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -298,7 +298,7 @@ describe('DotEmaShellComponent', () => { }); spectator.detectChanges(); - expect(store.load).toHaveBeenCalledWith({ + expect(store.init).toHaveBeenCalledWith({ clientHost: 'http://localhost:3000', language_id: 2, url: 'my-awesome-page', @@ -306,6 +306,20 @@ describe('DotEmaShellComponent', () => { }); }); + it("should not trigger a load when the queryParams didn't change", () => { + spectator.triggerNavigation({ + url: [], + queryParams: { + language_id: 1, + url: 'index', + 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier + } + }); + + spectator.detectChanges(); + expect(store.init).not.toHaveBeenCalledTimes(2); // The first call is on the beforeEach + }); + it('should trigger a load when changing the clientHost and it is on the allowedDevURLs', () => { spectator.triggerNavigation({ url: [], @@ -325,7 +339,7 @@ describe('DotEmaShellComponent', () => { }); spectator.detectChanges(); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:1111', language_id: 1, url: 'index', @@ -352,7 +366,7 @@ describe('DotEmaShellComponent', () => { }); spectator.detectChanges(); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:1111', language_id: 1, url: 'index', @@ -379,7 +393,7 @@ describe('DotEmaShellComponent', () => { }); spectator.detectChanges(); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:1111/', language_id: 1, url: 'index', @@ -406,7 +420,7 @@ describe('DotEmaShellComponent', () => { }); spectator.detectChanges(); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:1111/', language_id: 1, url: 'index', @@ -444,7 +458,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -482,7 +496,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -519,7 +533,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -557,7 +571,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -594,7 +608,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -625,7 +639,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -656,7 +670,7 @@ describe('DotEmaShellComponent', () => { queryParamsHandling: 'merge' }); - expect(store.load).toHaveBeenLastCalledWith({ + expect(store.init).toHaveBeenLastCalledWith({ clientHost: 'http://localhost:3000', language_id: 1, url: 'index', @@ -782,7 +796,7 @@ describe('DotEmaShellComponent', () => { }); })); - it('should open a dialog to create the page and navigate to default language if the user closes the dialog', fakeAsync(() => { + it('should open a dialog to create the page and navigate to default language if the user closes the dialog without saving', fakeAsync(() => { spectator.triggerNavigation({ url: [], queryParams: { @@ -811,7 +825,11 @@ describe('DotEmaShellComponent', () => { name: NG_CUSTOM_EVENTS.DIALOG_CLOSED } }), - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + form: { + status: FormStatus.DIRTY, + isTranslation: true + } }); expect(router.navigate).toHaveBeenCalledWith([], { @@ -820,9 +838,7 @@ describe('DotEmaShellComponent', () => { }); })); - it('should open a dialog to create the page and do nothing when the user creates the page correctly', fakeAsync(() => { - const reloadSpy = jest.spyOn(store, 'reload'); - + it('should open a dialog to create the page and navigate to default language if the user closes the dialog without saving and without editing ', fakeAsync(() => { spectator.triggerNavigation({ url: [], queryParams: { @@ -845,33 +861,26 @@ describe('DotEmaShellComponent', () => { spectator.detectChanges(); - spectator.triggerEventHandler(DotEmaDialogComponent, 'action', { - event: new CustomEvent('ng-event', { - detail: { - name: NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED - } - }), - payload: PAYLOAD_MOCK - }); - - spectator.detectChanges(); spectator.triggerEventHandler(DotEmaDialogComponent, 'action', { event: new CustomEvent('ng-event', { detail: { name: NG_CUSTOM_EVENTS.DIALOG_CLOSED } }), - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + form: { + status: FormStatus.PRISTINE, + isTranslation: true + } }); - spectator.detectChanges(); - - expect(router.navigate).not.toHaveBeenCalled(); - - expect(reloadSpy).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([], { + queryParams: { language_id: 1 }, + queryParamsHandling: 'merge' + }); })); - it('should open a dialog to create the page and do nothing when the user creates the page correctly with SAVE_PAGE', fakeAsync(() => { + it('should open a dialog to create the page and do nothing when the user creates the page correctly with SAVE_PAGE and closes the dialog', fakeAsync(() => { spectator.triggerNavigation({ url: [], queryParams: { @@ -894,15 +903,6 @@ describe('DotEmaShellComponent', () => { spectator.detectChanges(); - spectator.triggerEventHandler(DotEmaDialogComponent, 'action', { - event: new CustomEvent('ng-event', { - detail: { - name: NG_CUSTOM_EVENTS.SAVE_PAGE - } - }), - payload: PAYLOAD_MOCK - }); - spectator.detectChanges(); spectator.triggerEventHandler(DotEmaDialogComponent, 'action', { event: new CustomEvent('ng-event', { @@ -910,8 +910,13 @@ describe('DotEmaShellComponent', () => { name: NG_CUSTOM_EVENTS.DIALOG_CLOSED } }), - payload: PAYLOAD_MOCK + payload: PAYLOAD_MOCK, + form: { + isTranslation: true, + status: FormStatus.SAVED + } }); + spectator.detectChanges(); expect(router.navigate).not.toHaveBeenCalled(); @@ -937,9 +942,7 @@ describe('DotEmaShellComponent', () => { spectator.detectChanges(); - const dialog = spectator.debugElement.query(By.css('[data-testId="ema-dialog"]')); - - spectator.triggerEventHandler(dialog, 'action', { + spectator.triggerEventHandler(DotEmaDialogComponent, 'action', { event: new CustomEvent('ng-event', { detail: { name: NG_CUSTOM_EVENTS.SAVE_PAGE, @@ -947,7 +950,12 @@ describe('DotEmaShellComponent', () => { htmlPageReferer: '/my-awesome-page' } } - }) + }), + payload: PAYLOAD_MOCK, + form: { + status: FormStatus.SAVED, + isTranslation: false + } }); spectator.detectChanges(); @@ -959,32 +967,30 @@ describe('DotEmaShellComponent', () => { }); }); - it('should trigger a store load if the url is the same', () => { - const loadMock = jest.spyOn(store, 'load'); + it('should trigger a store reload if the url is the same', () => { + const reloadMock = jest.spyOn(store, 'reload'); spectator.detectChanges(); - const dialog = spectator.debugElement.query(By.css('[data-testId="ema-dialog"]')); - - spectator.triggerEventHandler(dialog, 'action', { + spectator.triggerEventHandler(DotEmaDialogComponent, 'action', { event: new CustomEvent('ng-event', { detail: { name: NG_CUSTOM_EVENTS.SAVE_PAGE, payload: { - htmlPageReferer: '/my-awesome-page' + htmlPageReferer: 'index' } } - }) + }), + payload: PAYLOAD_MOCK, + form: { + status: FormStatus.SAVED, + isTranslation: false + } }); spectator.detectChanges(); - expect(loadMock).toHaveBeenCalledWith({ - clientHost: 'http://localhost:3000', - language_id: 1, - url: 'index', - 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier - }); + expect(reloadMock).toHaveBeenCalled(); }); it('should reload content from dialog', () => { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts index d0935c96d2e..cceae7d4cdc 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts @@ -1,7 +1,7 @@ -import { combineLatest, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { Component, effect, inject, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { Component, effect, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ConfirmationService, MessageService } from 'primeng/api'; @@ -9,7 +9,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService } from 'primeng/dynamicdialog'; import { ToastModule } from 'primeng/toast'; -import { skip, take, takeUntil } from 'rxjs/operators'; +import { skip, takeUntil } from 'rxjs/operators'; import { DotESContentService, @@ -26,16 +26,16 @@ import { SiteService } from '@dotcms/dotcms-js'; import { DotLanguage } from '@dotcms/dotcms-models'; import { DotPageToolsSeoComponent } from '@dotcms/portlets/dot-ema/ui'; import { DotInfoPageComponent, DotNotLicenseComponent, SafeUrlPipe } from '@dotcms/ui'; +import { isEqual } from '@dotcms/utils/lib/shared/lodash/functions'; import { EditEmaNavigationBarComponent } from './components/edit-ema-navigation-bar/edit-ema-navigation-bar.component'; import { DotEmaDialogComponent } from '../components/dot-ema-dialog/dot-ema-dialog.component'; -import { EditEmaEditorComponent } from '../edit-ema-editor/edit-ema-editor.component'; import { DotActionUrlService } from '../services/dot-action-url/dot-action-url.service'; import { DotPageApiParams, DotPageApiService } from '../services/dot-page-api.service'; import { WINDOW } from '../shared/consts'; -import { NG_CUSTOM_EVENTS } from '../shared/enums'; -import { DotPage } from '../shared/models'; +import { FormStatus, NG_CUSTOM_EVENTS } from '../shared/enums'; +import { DialogAction, DotPage } from '../shared/models'; import { UVEStore } from '../store/dot-uve.store'; @Component({ @@ -80,8 +80,6 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { @ViewChild('dialog') dialog!: DotEmaDialogComponent; @ViewChild('pageTools') pageTools!: DotPageToolsSeoComponent; - readonly $didTranslate = signal(false); - readonly uveStore = inject(UVEStore); readonly #activatedRoute = inject(ActivatedRoute); @@ -93,28 +91,21 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { protected readonly $shellProps = this.uveStore.$shellProps; readonly #destroy$ = new Subject(); - #currentComponent: unknown; readonly translatePageEffect = effect(() => { - const { languages, languageId, page } = this.uveStore.$shellProps().translateProps; - - if (languages.length) { - const currentLanguage = languages.find((lang) => lang.id === languageId); - - if (!currentLanguage) { - return; - } + const { page, currentLanguage } = this.uveStore.$translateProps(); - if (!currentLanguage?.translated) { - this.createNewTranslation(currentLanguage, page); - } + if (currentLanguage && !currentLanguage?.translated) { + this.createNewTranslation(currentLanguage, page); } }); ngOnInit(): void { - combineLatest([this.#activatedRoute.data, this.#activatedRoute.queryParams]) + this.#activatedRoute.queryParams .pipe(takeUntil(this.#destroy$)) - .subscribe(([{ data }, queryParams]) => { + .subscribe((queryParams) => { + const { data } = this.#activatedRoute.snapshot.data; + // If we have a clientHost we need to check if it's in the whitelist if (queryParams.clientHost) { const canAccessClientHost = this.checkClientHostAccess( @@ -133,10 +124,17 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { } } - this.uveStore.load({ + const currentParams = { ...(queryParams as DotPageApiParams), clientHost: queryParams.clientHost ?? data?.url - }); + }; + + // We don't need to load if the params are the same + if (isEqual(this.uveStore.params(), currentParams)) { + return; + } + + this.uveStore.init(currentParams); }); // We need to skip one because it's the initial value @@ -150,40 +148,25 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { this.#destroy$.complete(); } - /** - * Handle the activate route event - * - * @param {*} event - * @memberof DotEmaShellComponent - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onActivateRoute(event: any): void { - this.#currentComponent = event; - } + handleNgEvent({ event, form }: DialogAction) { + const { isTranslation, status } = form; + + const isSaved = status === FormStatus.SAVED; - handleNgEvent({ event }: { event: CustomEvent }) { switch (event.detail.name) { case NG_CUSTOM_EVENTS.DIALOG_CLOSED: { - if (!this.$didTranslate()) { + if (!isSaved && isTranslation) { + // At this point we are in the language of the translation, if the user didn't save we need to navigate to the default language this.navigate({ - language_id: 1 // We navigate to the default language if the user didn't translate + language_id: 1 }); - } else { - this.$didTranslate.set(false); - this.reloadFromDialog(); } break; } - case NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED: { - // We need to check when the contentlet is updated, to know if we need to reload the page - this.$didTranslate.set(true); - break; - } - case NG_CUSTOM_EVENTS.SAVE_PAGE: { - this.$didTranslate.set(true); + // Maybe this can be out of the switch but we should evaluate it if it's needed // This can be undefined const url = event.detail.payload?.htmlPageReferer?.split('?')[0].replace('/', ''); @@ -195,18 +178,8 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { return; } - if (this.#currentComponent instanceof EditEmaEditorComponent) { - this.#currentComponent.reloadIframeContent(); - } - - this.#activatedRoute.data.pipe(take(1)).subscribe(({ data }) => { - const params = this.uveStore.params(); + this.uveStore.reload(); - this.uveStore.load({ - ...params, - clientHost: params.clientHost ?? data?.url - }); - }); break; } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts index 33dc4ef6240..82ff3f16ded 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts @@ -98,7 +98,7 @@ describe('DotEmaInfoDisplayComponent', () => { store = spectator.inject(UVEStore); - store.load({ + store.init({ clientHost: 'http://localhost:3000', url: 'index', language_id: '1', @@ -134,7 +134,7 @@ describe('DotEmaInfoDisplayComponent', () => { store = spectator.inject(UVEStore); - store.load({ + store.init({ clientHost: 'http://localhost:3000', url: 'index', language_id: '1', @@ -169,7 +169,7 @@ describe('DotEmaInfoDisplayComponent', () => { store = spectator.inject(UVEStore); router = spectator.inject(Router); - store.load({ + store.init({ clientHost: 'http://localhost:3000', url: 'index', language_id: '1', diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-language-selector/edit-ema-language-selector.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-language-selector/edit-ema-language-selector.component.ts index ae207328bf3..4b4da33e22c 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-language-selector/edit-ema-language-selector.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-language-selector/edit-ema-language-selector.component.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import { ButtonModule } from 'primeng/button'; -import { Listbox, ListboxModule } from 'primeng/listbox'; +import { Listbox, ListboxChangeEvent, ListboxModule } from 'primeng/listbox'; import { OverlayPanelModule } from 'primeng/overlaypanel'; import { map } from 'rxjs/operators'; @@ -20,10 +20,6 @@ import { map } from 'rxjs/operators'; import { DotLanguagesService } from '@dotcms/data-access'; import { DotLanguage } from '@dotcms/dotcms-models'; -interface DotLanguageWithLabel extends DotLanguage { - label: string; -} - @Component({ selector: 'dot-edit-ema-language-selector', standalone: true, @@ -58,23 +54,21 @@ export class EditEmaLanguageSelectorComponent implements AfterViewInit, OnChange ngOnChanges(): void { // To select the correct language when the page is reloaded with no queryParams if (this.listbox) { - this.listbox.value = this.selectedLanguage; - this.listbox.cd.detectChanges(); + this.listbox.writeValue(this.selectedLanguage); } } ngAfterViewInit(): void { - this.listbox.value = this.selectedLanguage; - this.listbox.cd.detectChanges(); + this.listbox.writeValue(this.selectedLanguage); } /** * Handle the change of the language * - * @param {{ event: Event; value:DotLanguageWithLabel }} { value } + * @param {ListboxChangeEvent} { value } * @memberof EmaLanguageSelectorComponent */ - onChange({ value }: { event: Event; value: DotLanguageWithLabel }) { + onChange({ value }: ListboxChangeEvent) { this.selected.emit(value.id); } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts index b0fa8b2a708..bf4f48df15c 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts @@ -369,7 +369,7 @@ describe('EditEmaEditorComponent', () => { addMessageSpy = jest.spyOn(messageService, 'add'); - store.load({ + store.init({ clientHost: 'http://localhost:3000', url: 'index', language_id: '1', @@ -422,7 +422,7 @@ describe('EditEmaEditorComponent', () => { spectator.activatedRouteStub.setQueryParam('variantName', 'hello-there'); spectator.detectChanges(); - store.load({ + store.init({ url: 'index', language_id: '5', 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier, @@ -444,7 +444,7 @@ describe('EditEmaEditorComponent', () => { spectator.detectChanges(); - store.load({ + store.init({ url: 'index', language_id: '5', 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier @@ -2411,7 +2411,9 @@ describe('EditEmaEditorComponent', () => { }); describe('DOM', () => { - it("should not show a loader when the editor state is not 'loading'", () => { + it('should not show a loader when client is ready and UVE is not loading', () => { + store.setIsClientReady(true); + store.setUveStatus(UVE_STATUS.LOADED); spectator.detectChanges(); const progressbar = spectator.query(byTestId('progress-bar')); @@ -2419,15 +2421,25 @@ describe('EditEmaEditorComponent', () => { expect(progressbar).toBeNull(); }); - it('should show a loader when the UVE is loading', () => { - store.setUveStatus(UVE_STATUS.LOADING); + it('should show a loader when the client is not ready', () => { + store.setIsClientReady(false); + spectator.detectChanges(); + + const progressbar = spectator.query(byTestId('progress-bar')); + expect(progressbar).not.toBeNull(); + }); + + it('should show a loader when the client is ready but UVE is Loading', () => { + store.setIsClientReady(true); + store.setUveStatus(UVE_STATUS.LOADING); // Almost impossible case but we have it as a fallback spectator.detectChanges(); const progressbar = spectator.query(byTestId('progress-bar')); expect(progressbar).not.toBeNull(); }); + it('iframe should have the correct src when is HEADLESS', () => { spectator.detectChanges(); @@ -2443,7 +2455,7 @@ describe('EditEmaEditorComponent', () => { jest.useFakeTimers(); // Mock the timers spectator.detectChanges(); - store.load({ + store.init({ url: 'index', language_id: '3', 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier @@ -2483,7 +2495,7 @@ describe('EditEmaEditorComponent', () => { iframe.nativeElement.contentWindow.scrollTo(0, 100); //Scroll down - store.load({ + store.init({ url: 'index', language_id: '4', 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier @@ -2583,7 +2595,7 @@ describe('EditEmaEditorComponent', () => { const url = "/ultra-cool-url-that-doesn't-exist"; - store.load({ + store.init({ url, language_id: '5', 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier @@ -2622,7 +2634,7 @@ describe('EditEmaEditorComponent', () => { spectator.activatedRouteStub.setQueryParam('variantName', 'hello-there'); spectator.detectChanges(); - store.load({ + store.init({ url: 'index', language_id: '5', 'com.dotmarketing.persona.id': DEFAULT_PERSONA.identifier, diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 1031bd76d5d..27bcc6d9a7e 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -166,6 +166,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy { if (!isTraditionalPage) { if (isClientReady) { + // This should have another name. return this.reloadIframeContent(); } @@ -951,7 +952,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy { const { query, params } = clientConfig || {}; const isClientReady = this.uveStore.isClientReady(); - // Frameworks Navigation triggers the client ready event, so we need to prevent it + // Frameworks Navigation triggers the client ready event, so we need to prevent it // Until we manually trigger the reload if (isClientReady) { return; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts index 07b4fcd9648..35f875b3b08 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts @@ -127,7 +127,7 @@ describe('EditEmaLayoutComponent', () => { dotPageLayoutService = spectator.inject(DotPageLayoutService); messageService = spectator.inject(MessageService); - store.load({ + store.init({ clientHost: 'http://localhost:3000', language_id: '1', url: 'test', diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts index 3b3d6eb06ea..053be21eff3 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts @@ -49,3 +49,15 @@ export enum CommonErrors { 'NOT_FOUND' = '404', 'ACCESS_DENIED' = '403' } + +export enum DialogStatus { + IDLE = 'IDLE', + LOADING = 'LOADING', + INIT = 'INIT' +} + +export enum FormStatus { + DIRTY = 'DIRTY', + SAVED = 'SAVED', + PRISTINE = 'PRISTINE' +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/mocks.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/mocks.ts index 5150520af67..6b795b1d3a3 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/mocks.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/mocks.ts @@ -10,8 +10,7 @@ import { mockDotLayout, mockDotTemplate, mockDotContainers, - dotcmsContentletMock, - mockLanguageArray + dotcmsContentletMock } from '@dotcms/utils-testing'; import { DEFAULT_PERSONA } from './consts'; @@ -714,11 +713,6 @@ export const BASE_SHELL_ITEMS = [ export const BASE_SHELL_PROPS_RESPONSE = { canRead: true, error: null, - translateProps: { - page: MOCK_RESPONSE_HEADLESS.page, - languageId: 1, - languages: mockLanguageArray - }, seoParams: { siteId: MOCK_RESPONSE_HEADLESS.site.identifier, languageId: 1, diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index e3c3f194e1f..c2306250ee5 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -1,7 +1,7 @@ import { DotDevice } from '@dotcms/dotcms-models'; import { InfoPage } from '@dotcms/ui'; -import { CommonErrors } from './enums'; +import { CommonErrors, DialogStatus, FormStatus } from './enums'; import { DotPageApiParams } from '../services/dot-page-api.service'; @@ -188,3 +188,43 @@ export interface DotDeviceWithIcon extends DotDevice { } export type CommonErrorsInfo = Record; + +export interface DialogForm { + status: FormStatus; + isTranslation: boolean; +} + +export interface DialogAction { + event: CustomEvent; + payload: ActionPayload; + form: DialogForm; +} + +export type DialogType = 'content' | 'form' | 'widget' | null; + +export interface EditEmaDialogState { + header: string; + status: DialogStatus; + url: string; + type: DialogType; + payload?: ActionPayload; + editContentForm: DialogForm; +} + +// We can modify this if we add more events, for now I think is enough +export interface CreateFromPaletteAction { + variable: string; + name: string; + payload: ActionPayload; +} + +export interface EditContentletPayload { + inode: string; + title: string; +} + +export interface CreateContentletAction { + url: string; + contentType: string; + payload: ActionPayload; +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts index 80a1629e85d..c6e5f810a45 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts @@ -159,10 +159,19 @@ describe('UVEStore', () => { buildPageAPIResponseFromMock(MOCK_RESPONSE_HEADLESS) ); - store.load(HEADLESS_BASE_QUERY_PARAMS); + store.init(HEADLESS_BASE_QUERY_PARAMS); }); describe('withComputed', () => { + describe('$translateProps', () => { + it('should return the page and the currentLanguage', () => { + expect(store.$translateProps()).toEqual({ + page: MOCK_RESPONSE_HEADLESS.page, + currentLanguage: mockLanguageArray[0] + }); + }); + }); + describe('$shellProps', () => { it('should return the shell props for Headless Pages', () => { expect(store.$shellProps()).toEqual(BASE_SHELL_PROPS_RESPONSE); @@ -223,16 +232,11 @@ describe('UVEStore', () => { buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(store.$shellProps()).toEqual({ canRead: true, error: null, - translateProps: { - page: MOCK_RESPONSE_VTL.page, - languageId: 1, - languages: mockLanguageArray - }, seoParams: { siteId: MOCK_RESPONSE_VTL.site.identifier, languageId: 1, @@ -294,13 +298,28 @@ describe('UVEStore', () => { }) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); const layoutItem = store.$shellProps().items.find((item) => item.id === 'layout'); expect(layoutItem.isDisabled).toBe(true); }); + it('should return layout, rules and experiments as disabled when isEnterprise is false', () => { + jest.spyOn(dotPageApiService, 'get').mockImplementation( + buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL) + ); + + patchState(store, { isEnterprise: false }); + + const shellProps = store.$shellProps(); + const layoutItem = shellProps.items.find((item) => item.id === 'layout'); + const rulesItem = shellProps.items.find((item) => item.id === 'rules'); + const experimentsItem = shellProps.items.find((item) => item.id === 'experiments'); + expect(layoutItem.isDisabled).toBe(true); + expect(rulesItem.isDisabled).toBe(true); + expect(experimentsItem.isDisabled).toBe(true); + }); it('should return item for layout as disable and with a tooltip', () => { jest.spyOn(dotPageApiService, 'get').mockImplementation( buildPageAPIResponseFromMock({ @@ -312,7 +331,7 @@ describe('UVEStore', () => { }) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); const layoutItem = store.$shellProps().items.find((item) => item.id === 'layout'); @@ -333,7 +352,7 @@ describe('UVEStore', () => { }) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); const rules = store.$shellProps().items.find((item) => item.id === 'rules'); const experiments = store @@ -388,6 +407,7 @@ describe('UVEStore', () => { expect(store.pageIsLocked()).toBe(false); expect(store.status()).toBe(UVE_STATUS.LOADED); expect(store.isTraditionalPage()).toBe(false); + expect(store.isClientReady()).toBe(false); }); it('should load the store with the base data for traditional page', () => { @@ -395,7 +415,7 @@ describe('UVEStore', () => { buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_VTL); expect(store.isEnterprise()).toBe(true); @@ -407,6 +427,7 @@ describe('UVEStore', () => { expect(store.pageIsLocked()).toBe(false); expect(store.status()).toBe(UVE_STATUS.LOADED); expect(store.isTraditionalPage()).toBe(true); + expect(store.isClientReady()).toBe(true); }); it('should navigate when the page is a vanityUrl permanent redirect', () => { @@ -421,7 +442,7 @@ describe('UVEStore', () => { of(permanentRedirect) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(router.navigate).toHaveBeenCalledWith([], { queryParams: { @@ -444,7 +465,7 @@ describe('UVEStore', () => { of(temporaryRedirect) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(router.navigate).toHaveBeenCalledWith([], { queryParams: { @@ -478,7 +499,7 @@ describe('UVEStore', () => { } as unknown as ActivatedRouteSnapshot } as unknown as ActivatedRoute); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], { queryParamsHandling: 'merge' @@ -508,7 +529,7 @@ describe('UVEStore', () => { } as unknown as ActivatedRouteSnapshot } as unknown as ActivatedRoute); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], { queryParamsHandling: 'merge' @@ -538,7 +559,7 @@ describe('UVEStore', () => { } as unknown as ActivatedRouteSnapshot } as unknown as ActivatedRoute); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -566,7 +587,7 @@ describe('UVEStore', () => { } as unknown as ActivatedRouteSnapshot } as unknown as ActivatedRoute); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -1094,13 +1115,13 @@ describe('UVEStore', () => { buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(store.$reloadEditorContent()).toEqual({ code: MOCK_RESPONSE_VTL.page.rendered, isTraditionalPage: true, enableInlineEdit: true, - isClientReady: false + isClientReady: true }); }); }); @@ -1129,7 +1150,7 @@ describe('UVEStore', () => { src: 'http://localhost:3000/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000', wrapper: null }, - progressBar: false, + progressBar: true, contentletTools: null, dropzone: null, palette: { @@ -1191,7 +1212,7 @@ describe('UVEStore', () => { buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL) ); - store.load(VTL_BASE_QUERY_PARAMS); + store.init(VTL_BASE_QUERY_PARAMS); expect(store.$editorProps().iframe.src).toBe(''); }); @@ -1214,6 +1235,18 @@ describe('UVEStore', () => { expect(store.$editorProps().progressBar).toBe(true); }); + + it('should have progressBar as true when the status is loaded but client is not ready', () => { + patchState(store, { status: UVE_STATUS.LOADED, isClientReady: false }); + + expect(store.$editorProps().progressBar).toBe(true); + }); + + it('should have progressBar as false when the status is loaded and client is ready', () => { + patchState(store, { status: UVE_STATUS.LOADED, isClientReady: true }); + + expect(store.$editorProps().progressBar).toBe(false); + }); }); describe('contentletTools', () => { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts index 80506a47c69..9e1f001afeb 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts @@ -5,7 +5,7 @@ import { computed } from '@angular/core'; import { withEditor } from './features/editor/withEditor'; import { withLayout } from './features/layout/withLayout'; import { withLoad } from './features/load/withLoad'; -import { ShellProps, UVEState } from './models'; +import { ShellProps, TranslateProps, UVEState } from './models'; import { DotPageApiResponse } from '../services/dot-page-api.service'; import { UVE_STATUS } from '../shared/enums'; @@ -28,8 +28,29 @@ const initialState: UVEState = { export const UVEStore = signalStore( withState(initialState), withComputed( - ({ pageAPIResponse, isTraditionalPage, params, languages, errorCode: error, status }) => { + ({ + pageAPIResponse, + isTraditionalPage, + params, + languages, + errorCode: error, + status, + isEnterprise + }) => { return { + $translateProps: computed(() => { + const response = pageAPIResponse(); + const languageId = response?.viewAs.language?.id; + const translatedLanguages = languages(); + const currentLanguage = translatedLanguages.find( + (lang) => lang.id === languageId + ); + + return { + page: response?.page, + currentLanguage + }; + }), $shellProps: computed(() => { const response = pageAPIResponse(); @@ -41,22 +62,15 @@ export const UVEStore = signalStore( const templateDrawed = response?.template.drawed; const isLayoutDisabled = !page?.canEdit || !templateDrawed; - - const languageId = response?.viewAs.language?.id; - const translatedLanguages = languages(); const errorCode = error(); const errorPayload = getErrorPayload(errorCode); const isLoading = status() === UVE_STATUS.LOADING; + const isEnterpriseLicense = isEnterprise(); return { canRead: page?.canRead, error: errorPayload, - translateProps: { - page, - languageId, - languages: translatedLanguages - }, seoParams: { siteId: response?.site?.identifier, languageId: response?.viewAs.language.id, @@ -75,7 +89,7 @@ export const UVEStore = signalStore( label: 'editema.editor.navbar.layout', href: 'layout', id: 'layout', - isDisabled: isLayoutDisabled, + isDisabled: isLayoutDisabled || !isEnterpriseLicense, tooltip: templateDrawed ? null : 'editema.editor.navbar.layout.tooltip.cannot.edit.advanced.template' @@ -85,14 +99,14 @@ export const UVEStore = signalStore( label: 'editema.editor.navbar.rules', id: 'rules', href: `rules/${page?.identifier}`, - isDisabled: !page?.canEdit + isDisabled: !page?.canEdit || !isEnterpriseLicense }, { iconURL: 'experiments', label: 'editema.editor.navbar.experiments', href: `experiments/${page?.identifier}`, id: 'experiments', - isDisabled: !page?.canEdit + isDisabled: !page?.canEdit || !isEnterpriseLicense }, { icon: 'pi-th-large', diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts index 23d6bae65c9..790bc261009 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts @@ -112,7 +112,8 @@ export function withEditor() { const bounds = store.bounds(); const dragItem = store.dragItem(); const isEditState = store.isEditState(); - const isLoading = store.status() === UVE_STATUS.LOADING; + const isLoading = !isClientReady || store.status() === UVE_STATUS.LOADING; + const isPageReady = isTraditionalPage || isClientReady; const { dragIsActive, isScrolling } = getEditorStates(state); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts index 3d7658dc5de..e361762eeff 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts @@ -40,7 +40,7 @@ export function withLoad() { const activatedRoute = inject(ActivatedRoute); return { - load: rxMethod( + init: rxMethod( pipe( tap(() => store.resetClientConfiguration()), tap(() => { @@ -144,7 +144,7 @@ export function withLoad() { canEditPage, pageIsLocked, isTraditionalPage, - isClientReady: false, + isClientReady: isTraditionalPage, // If is a traditional page we are ready status: UVE_STATUS.LOADED }); }, diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts index 5e8a40779fe..18b329ffe40 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts @@ -27,12 +27,10 @@ export interface ShellProps { pageInfo: InfoPage; }; items: NavigationBarItem[]; - translateProps: TranslateProps; seoParams: DotPageToolUrlParams; } export interface TranslateProps { page: DotPage; - languageId: number; - languages: DotLanguage[]; + currentLanguage: DotLanguage; } diff --git a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html index e73c00a148d..13aec9de388 100644 --- a/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html +++ b/core-web/libs/template-builder/src/lib/components/template-builder/components/template-builder-box/template-builder-box.component.html @@ -67,7 +67,8 @@

{{ - (containerMap && containerMap[item.identifier].title) || item.identifier + (containerMap && containerMap[item.identifier]?.title) || + item.identifier }}

({ + setModel: () => {}, + dispose: () => {}, + onDidChangeModelContent: (listener: () => void) => ({ + dispose: () => {} + }), + getValue: () => '', + setValue: (value: string) => { + console.log(`Editor value set to: ${value}`); + }, + getModel: () => ({ + uri: { + path: '/some/path' + } + }), + updateOptions: (options: object) => { + console.log('Editor options updated:', options); + }, + onDidChangeModelDecorations: (callback: () => void) => { + console.log('Model decorations changed'); + callback(); + + return { + dispose: () => { + console.log('Listener disposed'); + } + }; + }, + // Nuevas funciones agregadas + onDidBlurEditorText: (listener: () => void) => ({ + dispose: () => {} + }), + onDidFocusEditorText: (listener: () => void) => ({ + dispose: () => {} + }), + layout: (dimension?: { width: number; height: number }) => { + console.log('Editor layout updated:', dimension); + }, + getPosition: () => ({ + lineNumber: 1, + column: 1 + }), + setPosition: (position: { lineNumber: number; column: number }) => { + console.log('Editor position set to:', position); + }, + revealLine: (lineNumber: number) => { + console.log('Revealing line:', lineNumber); + } + }), + setModelLanguage: () => {}, + createModel: () => ({ + dispose: () => {} + }), + setTheme: () => {}, + getModelMarkers: (model: object) => { + console.log('Getting model markers for model:', model); + + return [ + { + severity: 1, + message: 'Simulated error', + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 5 + } + ]; + } + }, + languages: { + register: () => {}, + registerCompletionItemProvider: () => {}, + registerDefinitionProvider: () => {} + }, + Uri: { + parse: () => ({}), + file: () => ({}) + } +}; diff --git a/docker/docker-compose-examples/analytics/setup/config/dev/cube/schema/Events.js b/docker/docker-compose-examples/analytics/setup/config/dev/cube/schema/Events.js index 9e0ecd25eb9..f6aada775d4 100644 --- a/docker/docker-compose-examples/analytics/setup/config/dev/cube/schema/Events.js +++ b/docker/docker-compose-examples/analytics/setup/config/dev/cube/schema/Events.js @@ -148,4 +148,72 @@ cube(`Events`, { } }, dataSource: `default` -}); \ No newline at end of file +}); + +cube('request', { + sql: `SELECT request_id, + MAX(sessionid) as sessionid, + (MAX(sessionnew) == 1)::bool as isSessionNew, + MIN(utc_time) as createdAt, + MAX(source_ip) as source_ip, + MAX(language) as language, + MAX(user_agent) as user_agent, + MAX(host) as host, + MAX(CASE WHEN event_type = 'PAGE_REQUEST' THEN object_id ELSE NULL END) as page_id, + MAX(CASE WHEN event_type = 'PAGE_REQUEST' THEN object_title ELSE NULL END) as page_title, + MAX(CASE WHEN event_type = 'FILE_REQUEST' THEN object_id ELSE NULL END) as file_id, + MAX(CASE WHEN event_type = 'FILE_REQUEST' THEN object_title ELSE NULL END) as file_title, + MAX(CASE WHEN event_type = 'VANITY_REQUEST' THEN object_id ELSE NULL END) as vanity_id, + MAX(CASE WHEN event_type = 'VANITY_REQUEST' THEN object_forward_to ELSE NULL END) as vanity_forward_to, + MAX(CASE WHEN event_type = 'VANITY_REQUEST' THEN object_response ELSE NULL END) as vanity_response, + (SUM(CASE WHEN event_type = 'VANITY_REQUEST' THEN 1 ELSE 0 END) > 0)::bool as was_vanity_url_hit, + MAX(CASE WHEN event_type = 'VANITY_REQUEST' THEN comefromvanityurl ELSE NULL END) as come_from_vanity_url, + (SUM(CASE WHEN event_type = 'URL_MAP' THEN 1 ELSE 0 END) > 0)::bool as url_map_match, + MAX(CASE WHEN event_type = 'URL_MAP' THEN object_id ELSE NULL END) as url_map_content_detail_id, + MAX(CASE WHEN event_type = 'URL_MAP' THEN object_title ELSE NULL END) as url_map_content_detail_title, + MAX(CASE WHEN event_type = 'URL_MAP' THEN object_content_type_id ELSE NULL END) as url_map_content_type_id, + MAX(CASE WHEN event_type = 'URL_MAP' THEN object_content_type_name ELSE NULL END) as url_map_content_type_name, + MAX(CASE WHEN event_type = 'URL_MAP' THEN object_content_type_var_name ELSE NULL END) as url_map_content_type_var_name, + MAX(CASE WHEN event_type = 'URL_MAP' THEN object_detail_page_url ELSE NULL END) as url_map_detail_page_url, + MAX(url) AS url, + CASE + WHEN MAX(CASE WHEN event_type = 'FILE_REQUEST' THEN 1 ELSE 0 END) = 1 THEN 'FILE' + WHEN MAX(CASE WHEN event_type = 'PAGE_REQUEST' THEN 1 ELSE 0 END) = 1 THEN 'PAGE' + ELSE 'NOTHING' + END AS what_am_i + FROM events + GROUP BY request_id`, + dimensions: { + requestId: { sql: 'request_id', type: `string` }, + sessionId: { sql: 'sessionid', type: `string` }, + isSessionNew: { sql: 'isSessionNew', type: `boolean` }, + createdAt: { sql: 'createdAt', type: `time`, }, + whatAmI: { sql: 'what_am_i', type: `string` }, + sourceIp: { sql: 'source_ip', type: `string` }, + language: { sql: 'language', type: `string` }, + userAgent: { sql: 'user_agent', type: `string` }, + host: { sql: 'host', type: `string` }, + url: { sql: 'url', type: `string` }, + pageId: { sql: 'page_id', type: `string` }, + pageTitle: { sql: 'page_title', type: `string` }, + fileId: { sql: 'file_id', type: `string` }, + fileTitle: { sql: 'file_title', type: `string` }, + wasVanityHit: { sql: 'was_vanity_url_hit', type: `boolean` }, + vanityId: { sql: 'vanity_id', type: `string` }, + vanityForwardTo: { sql: 'vanity_forward_to', type: `string` }, + vanityResponse: { sql: 'vanity_response', type: `string` }, + comeFromVanityURL: { sql: 'come_from_vanity_url', type: `boolean` }, + urlMapWasHit: { sql: 'url_map_match', type: `boolean` }, + isDetailPage: { sql: "url_map_detail_page_url is not null and url_map_detail_page_url != ''", type: `boolean` }, + urlMapContentDetailId: { sql: 'url_map_content_detail_id', type: `string` }, + urlMapContentDetailTitle: { sql: 'url_map_content_detail_title', type: `string` }, + urlMapContentId: { sql: 'url_map_content_type_id', type: `string` }, + urlMapContentTypeName: { sql: 'url_map_content_type_name', type: `string` }, + urlMapContentTypeVarName: { sql: 'url_map_content_type_var_name', type: `string` }, + }, + measures: { + count: { + type: "count" + } + } +}); diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml index b264b89d03e..0563719b802 100644 --- a/dotCMS/pom.xml +++ b/dotCMS/pom.xml @@ -1354,12 +1354,6 @@ log4j-jcl runtime - - - org.apache.logging.log4j - log4j-jcl - runtime - org.slf4j slf4j-api diff --git a/dotCMS/src/main/java/com/dotcms/analytics/Util.java b/dotCMS/src/main/java/com/dotcms/analytics/Util.java new file mode 100644 index 00000000000..3b15f6ba8cc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/Util.java @@ -0,0 +1,35 @@ +package com.dotcms.analytics; + +import com.dotmarketing.business.APILocator; +import com.dotmarketing.cms.urlmap.UrlMapContext; +import com.dotmarketing.util.Logger; +import io.vavr.control.Try; + +import static com.dotcms.exception.ExceptionUtil.getErrorMessage; + +/** + * This utility class exposes common-use methods for the Analytics APIs. + * + * @author Jose Castro + * @since Sep 13th, 2024 + */ +public class Util { + + private Util() { + // Singleton + } + /** + * Based on the specified URL Map Context, determines whether a given incoming URL maps to a URL + * Mapped content or not. + * + * @param urlMapContext UrlMapContext object containing the following information: + * @return If the URL maps to URL Mapped content, returns {@code true}. + */ + public static boolean isUrlMap(final UrlMapContext urlMapContext) { + return Try.of(() -> APILocator.getURLMapAPI().isUrlPattern(urlMapContext)) + .onFailure(e -> Logger.error(Util.class, String.format("Failed to check for URL Mapped content for page '%s': %s", + urlMapContext.getUri(), getErrorMessage(e)), e)) + .getOrElse(false); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java new file mode 100644 index 00000000000..4d7bc5d9391 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java @@ -0,0 +1,158 @@ +package com.dotcms.analytics.query; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import java.util.Set; + +/** + * Encapsulates a simple query for the analytics backend + * Example: + *
+ *     {
+ *     "query": {
+ *         "dimensions": [
+ *             "Events.experiment",
+ *             "Events.variant",
+ *             "Events.lookBackWindow"
+ *         ],
+ *         "measures": [
+ *             "Events.count"
+ *         ],
+ *         "filters": "Events.variant = ['B']",
+ *         "limit": 100,
+ *         "offset": 1,
+ *         "timeDimensions": "Events.day day",
+ *         "orders": "Events.day ASC"
+ *     }
+ * }
+ *
+ * @see AnalyticsQueryParser
+ * 
+ * @author jsanca + */ +@JsonDeserialize(builder = AnalyticsQuery.Builder.class) +public class AnalyticsQuery { + + private final Set dimensions; // ["Events.referer", "Events.experiment", "Events.variant", "Events.utcTime", "Events.url", "Events.lookBackWindow", "Events.eventType"] + private final Set measures; // ["Events.count", "Events.uniqueCount"] + private final String filters; // Events.variant = ["B"] or Events.experiments = ["B"] + private final long limit; + private final long offset; + private final String timeDimensions; // Events.day day + private String orders; // Events.day ASC + + private AnalyticsQuery(final Builder builder) { + this.dimensions = builder.dimensions; + this.measures = builder.measures; + this.filters = builder.filters; + this.limit = builder.limit; + this.offset = builder.offset; + this.timeDimensions = builder.timeDimensions; + this.orders = builder.orders; + } + + public Set getDimensions() { + return dimensions; + } + + public Set getMeasures() { + return measures; + } + + public String getFilters() { + return filters; + } + + public long getLimit() { + return limit; + } + + public long getOffset() { + return offset; + } + + public String getTimeDimensions() { + return timeDimensions; + } + + public String getOrders() { + return orders; + } + + public static class Builder { + + @JsonProperty() + private Set dimensions; + @JsonProperty() + private Set measures; + @JsonProperty() + private String filters; + @JsonProperty() + private long limit; + @JsonProperty() + private long offset; + @JsonProperty() + private String timeDimensions; + @JsonProperty() + private String orders; + + + public Builder dimensions(Set dimensions) { + this.dimensions = dimensions; + return this; + } + + public Builder measures(Set measures) { + this.measures = measures; + return this; + } + + public Builder filters(String filters) { + this.filters = filters; + return this; + } + + public Builder limit(long limit) { + this.limit = limit; + return this; + } + + public Builder offset(long offset) { + this.offset = offset; + return this; + } + + public Builder timeDimensions(String timeDimensions) { + this.timeDimensions = timeDimensions; + return this; + } + + public Builder orders(String orders) { + this.orders = orders; + return this; + } + + public AnalyticsQuery build() { + return new AnalyticsQuery(this); + } + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + return "AnalyticsQuery{" + + "dimensions=" + dimensions + + ", measures=" + measures + + ", filters='" + filters + '\'' + + ", limit=" + limit + + ", offset=" + offset + + ", timeDimensions='" + timeDimensions + '\'' + + ", orders='" + orders + '\'' + + '}'; + } +} + diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java new file mode 100644 index 00000000000..de875e1534e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java @@ -0,0 +1,192 @@ +package com.dotcms.analytics.query; + +import com.dotcms.cube.CubeJSQuery; +import com.dotcms.cube.filters.Filter; +import com.dotcms.cube.filters.LogicalFilter; +import com.dotcms.cube.filters.SimpleFilter; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.core.JsonProcessingException; +import io.vavr.Tuple2; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Parser for the analytics query, it can parse a json string to a {@link AnalyticsQuery} or a {@link CubeJSQuery} + * @author jsanca + */ +public class AnalyticsQueryParser { + + /** + * Parse a json string to a {@link AnalyticsQuery} + * Example: + * { + * "dimensions": ["Events.referer", "Events.experiment", "Events.variant", "Events.utcTime", "Events.url", "Events.lookBackWindow", "Events.eventType"], + * "measures": ["Events.count", "Events.uniqueCount"], + * "filters": "Events.variant = ['B'] or Events.experiments = ['B']", + * "limit":100, + * "offset":1, + * "timeDimensions":"Events.day day", + * "orders":"Events.day ASC" + * } + * @param json + * @return AnalyticsQuery + */ + public AnalyticsQuery parseJsonToQuery(final String json) { + + if (Objects.isNull(json)) { + throw new IllegalArgumentException("Json can not be null"); + } + try { + + Logger.debug(this, ()-> "Parsing json to query: " + json); + return DotObjectMapperProvider.getInstance().getDefaultObjectMapper() + .readValue(json, AnalyticsQuery.class); + } catch (JsonProcessingException e) { + Logger.error(this, e.getMessage(), e); + throw new DotRuntimeException(e); + } + } + + /** + * Parse a json string to a {@link CubeJSQuery} + * Example: + * { + * "dimensions": ["Events.referer", "Events.experiment", "Events.variant", "Events.utcTime", "Events.url", "Events.lookBackWindow", "Events.eventType"], + * "measures": ["Events.count", "Events.uniqueCount"], + * "filters": "Events.variant = ['B'] or Events.experiments = ['B']", + * "limit":100, + * "offset":1, + * "timeDimensions":"Events.day day", + * "orders":"Events.day ASC" + * } + * @param json + * @return CubeJSQuery + */ + public CubeJSQuery parseJsonToCubeQuery(final String json) { + + Logger.debug(this, ()-> "Parsing json to cube query: " + json); + final AnalyticsQuery query = parseJsonToQuery(json); + return parseQueryToCubeQuery(query); + } + + /** + * Parse an {@link AnalyticsQuery} to a {@link CubeJSQuery} + * @param query + * @return CubeJSQuery + */ + public CubeJSQuery parseQueryToCubeQuery(final AnalyticsQuery query) { + + if (Objects.isNull(query)) { + throw new IllegalArgumentException("Query can not be null"); + } + + final CubeJSQuery.Builder builder = new CubeJSQuery.Builder(); + Logger.debug(this, ()-> "Parsing query to cube query: " + query); + + if (UtilMethods.isSet(query.getDimensions())) { + builder.dimensions(query.getDimensions()); + } + + if (UtilMethods.isSet(query.getMeasures())) { + builder.measures(query.getMeasures()); + } + + if (UtilMethods.isSet(query.getFilters())) { + builder.filters(parseFilters(query.getFilters())); + } + + builder.limit(query.getLimit()).offset(query.getOffset()); + + if (UtilMethods.isSet(query.getOrders())) { + builder.orders(parseOrders(query.getOrders())); + } + + if (UtilMethods.isSet(query.getTimeDimensions())) { + builder.timeDimensions(parseTimeDimensions(query.getTimeDimensions())); + } + + return builder.build(); + } + + private Collection parseTimeDimensions(final String timeDimensions) { + final TimeDimensionParser.TimeDimension parsedTimeDimension = TimeDimensionParser.parseTimeDimension(timeDimensions); + return Stream.of( + new CubeJSQuery.TimeDimension(parsedTimeDimension.getTerm(), + parsedTimeDimension.getField()) + ).collect(Collectors.toList()); + } + + private Collection parseOrders(final String orders) { + + final OrderParser.ParsedOrder parsedOrder = OrderParser.parseOrder(orders); + return Stream.of( + new CubeJSQuery.OrderItem(parsedOrder.getTerm(), + "ASC".equalsIgnoreCase(parsedOrder.getOrder())? + Filter.Order.ASC:Filter.Order.DESC) + ).collect(Collectors.toList()); + } + + private Collection parseFilters(final String filters) { + final Tuple2,List> result = + FilterParser.parseFilterExpression(filters); + + final List filterList = new ArrayList<>(); + final List simpleFilters = new ArrayList<>(); + + for (final FilterParser.Token token : result._1) { + + simpleFilters.add( + new SimpleFilter(token.member, + parseOperator(token.operator), + new Object[]{token.values})); + } + + // if has operators + if (UtilMethods.isSet(result._2())) { + + FilterParser.LogicalOperator logicalOperator = result._2().get(0); // first one + LogicalFilter.Builder logicalFilterBuilder = logicalOperator == FilterParser.LogicalOperator.AND? + LogicalFilter.Builder.and():LogicalFilter.Builder.or(); + + LogicalFilter logicalFilterFirst = logicalFilterBuilder.add(simpleFilters.get(0)).add(simpleFilters.get(1)).build(); + for (int i = 1; i < result._2().size(); i++) { // nest the next ones + + logicalOperator = result._2().get(i); + logicalFilterBuilder = logicalOperator == FilterParser.LogicalOperator.AND? + LogicalFilter.Builder.and():LogicalFilter.Builder.or(); + + logicalFilterFirst = logicalFilterBuilder.add(logicalFilterFirst) + .add(simpleFilters.get(i + 1)).build(); + } + + filterList.add(logicalFilterFirst); + } else { + filterList.addAll(simpleFilters); + } + + return filterList; + } + + private SimpleFilter.Operator parseOperator(final String operator) { + switch (operator) { + case "=": + return SimpleFilter.Operator.EQUALS; + case "!=": + return SimpleFilter.Operator.NOT_EQUALS; + case "in": + return SimpleFilter.Operator.CONTAINS; + case "!in": + return SimpleFilter.Operator.NOT_CONTAINS; + default: + throw new DotRuntimeException("Operator not supported: " + operator); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/FilterParser.java b/dotCMS/src/main/java/com/dotcms/analytics/query/FilterParser.java new file mode 100644 index 00000000000..c176db1fabd --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/query/FilterParser.java @@ -0,0 +1,96 @@ +package com.dotcms.analytics.query; + +import io.vavr.Tuple; +import io.vavr.Tuple2; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for a filter expression + * Example: + *
+ *     FilterParser.parseFilterExpression("Events.variant = ['B'] or Events.experiments = ['C']");
+ * 
+ * should return 2 tokens and 1 logical operator + * Tokens are member, operator and values (Events.variant, =, B) and the operator is 'and' or 'or' + * @author jsanca + */ +public class FilterParser { + + private static final String EXPRESSION_REGEX = "(\\w+\\.\\w+)\\s*(=|!=|in|!in)\\s*\\['(.*?)'"; + private static final String LOGICAL_OPERATOR_REGEX = "\\s*(and|or)\\s*"; + private static final Pattern TOKEN_PATTERN = Pattern.compile(EXPRESSION_REGEX); + private static final Pattern LOGICAL_PATTERN = Pattern.compile(LOGICAL_OPERATOR_REGEX); + + static class Token { + String member; + String operator; + String values; + + public Token(final String member, + final String operator, + final String values) { + this.member = member; + this.operator = operator; + this.values = values; + } + } + + enum LogicalOperator { + AND, + OR, + UNKNOWN + } + + /** + * This method parser the filter expression such as + * [Events.variant = [“B”] or Events.experiments = [“B”]] + * @param expression String + * @return return the token expression plus the logical operators + */ + public static Tuple2,List> parseFilterExpression(final String expression) { + + final List tokens = new ArrayList<>(); + final List logicalOperators = new ArrayList<>(); + // note:Need to use cache here + final Matcher tokenMatcher = TOKEN_PATTERN.matcher(expression); + + // Extract the tokens (member, operator, values) + while (tokenMatcher.find()) { + final String member = tokenMatcher.group(1); // Example: Events.variant + final String operator = tokenMatcher.group(2); // Example: = + final String values = tokenMatcher.group(3); // Example: "B" + tokens.add(new Token(member, operator, values)); + } + + // Pattern for logical operators (and, or) + // Need to use cache here + final Matcher logicalMatcher = LOGICAL_PATTERN.matcher(expression); + + // Extract logical operators + while (logicalMatcher.find()) { + final String logicalOperator = logicalMatcher.group(1); // Example: or, and + logicalOperators.add(parseLogicalOperator(logicalOperator)); + } + + // if any unknown should fails + // note: should validate logical operators should be length - 1 of the tokens??? + + return Tuple.of(tokens, logicalOperators); + } + + private static LogicalOperator parseLogicalOperator(final String logicalOperator) { + + switch (logicalOperator.toLowerCase()) { + case "and": + return LogicalOperator.AND; + case "or": + return LogicalOperator.OR; + default: + return LogicalOperator.UNKNOWN; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/OrderParser.java b/dotCMS/src/main/java/com/dotcms/analytics/query/OrderParser.java new file mode 100644 index 00000000000..06dbb61e37e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/query/OrderParser.java @@ -0,0 +1,68 @@ +package com.dotcms.analytics.query; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Order parser + * Example: + *
+ *     OrderParser.parseOrder("Events.day     ASC");
+ * 
+ * + * should return Events.day and ASC (term and order) + * if the order is not ASC or DESC or is missing will throw {@link IllegalArgumentException} + * @author jsanca + */ +public class OrderParser { + + private OrderParser () { + // singleton + } + // Expression for order + private static final String ORDER_REGEX = "(\\w+\\.\\w+)\\s+(ASC|DESC)"; + + public static class ParsedOrder { + private String term; + private String order; + + public ParsedOrder(final String term, final String order) { + this.term = term; + this.order = order; + } + + public String getTerm() { + return term; + } + + public String getOrder() { + return order; + } + + @Override + public String toString() { + return "Term: " + term + ", Order: " + order; + } + } + + public static ParsedOrder parseOrder(final String expression) throws IllegalArgumentException { + + if (Objects.isNull(expression)) { + throw new IllegalArgumentException("The expression can not be null."); + } + + // this should be cached and checked + final Pattern pattern = Pattern.compile(ORDER_REGEX, Pattern.CASE_INSENSITIVE); + final Matcher matcher = pattern.matcher(expression.trim()); + + if (matcher.matches()) { + String term = matcher.group(1); // Ex: Events.day + String order = matcher.group(2).toUpperCase(); // Ex: ASC o DESC + + return new ParsedOrder(term, order); + } else { + throw new IllegalArgumentException("The expression is not valid. The format should be 'Term ASC' or 'Term DESC'."); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/TimeDimensionParser.java b/dotCMS/src/main/java/com/dotcms/analytics/query/TimeDimensionParser.java new file mode 100644 index 00000000000..92db4faa965 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/query/TimeDimensionParser.java @@ -0,0 +1,63 @@ +package com.dotcms.analytics.query; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Time Dimension Parser + * Example: + *
+ *     TimeDimensionParser.parseTimeDimension("Events.day day");
+ * 
+ * + * This should return Events.day and day (term and field) + * Note: this is not support intervals for dates, but will introduce on the future + * @author jsanca + */ +public class TimeDimensionParser { + + private TimeDimensionParser() { + // singleton + } + + private static final String FIELD_REGEX = "(\\w+\\.\\w+)\\s+(\\w+)"; + + public static class TimeDimension { + private String term; + private String field; + + public TimeDimension(final String term, final String field) { + this.term = term; + this.field = field; + } + + public String getTerm() { + return term; + } + + public String getField() { + return field; + } + + @Override + public String toString() { + return "Term: " + term + ", Field: " + field; + } + } + + public static TimeDimension parseTimeDimension(final String expression) throws IllegalArgumentException { + // cache and checked + final Pattern pattern = Pattern.compile(FIELD_REGEX); + final Matcher matcher = pattern.matcher(expression.trim()); + + if (matcher.matches()) { + + final String term = matcher.group(1); // Ex: Events.day + final String field = matcher.group(2); // Ex: day + + return new TimeDimension(term, field); + } else { + throw new IllegalArgumentException("The expression is not valid. This should be the format 'Term Field'."); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java b/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java index 64b448b6e29..c5dc4260695 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java @@ -1,12 +1,17 @@ package com.dotcms.analytics.track; +import com.dotcms.analytics.track.collectors.WebEventsCollectorServiceFactory; +import com.dotcms.analytics.track.matchers.FilesRequestMatcher; +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.analytics.track.matchers.RequestMatcher; +import com.dotcms.analytics.track.matchers.VanitiesRequestMatcher; import com.dotcms.filters.interceptor.Result; import com.dotcms.filters.interceptor.WebInterceptor; -import com.dotcms.jitsu.EventLogSubmitter; import com.dotcms.util.CollectionsUtils; import com.dotcms.util.WhiteBlackList; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UUIDUtil; import com.liferay.util.StringPool; import javax.servlet.http.HttpServletRequest; @@ -25,8 +30,6 @@ public class AnalyticsTrackWebInterceptor implements WebInterceptor { private final static Map requestMatchersMap = new ConcurrentHashMap<>(); - private final EventLogSubmitter submitter; - /// private static final String[] DEFAULT_BLACKLISTED_PROPS = new String[]{"^/api/*"}; private static final String[] DEFAULT_BLACKLISTED_PROPS = new String[]{StringPool.BLANK}; private final WhiteBlackList whiteBlackList = new WhiteBlackList.Builder() @@ -37,11 +40,10 @@ public class AnalyticsTrackWebInterceptor implements WebInterceptor { public AnalyticsTrackWebInterceptor() { - submitter = new EventLogSubmitter(); addRequestMatcher( new PagesAndUrlMapsRequestMatcher(), new FilesRequestMatcher(), - new RulesRedirectsRequestMatcher(), + // new RulesRedirectsRequestMatcher(), new VanitiesRequestMatcher()); } @@ -64,6 +66,7 @@ public static void removeRequestMatcher(final String requestMatcherId) { requestMatchersMap.remove(requestMatcherId); } + @Override public Result intercept(final HttpServletRequest request, final HttpServletResponse response) throws IOException { @@ -71,14 +74,21 @@ public Result intercept(final HttpServletRequest request, final HttpServletRespo final Optional matcherOpt = this.anyMatcher(request, response, RequestMatcher::runBeforeRequest); if (matcherOpt.isPresent()) { + addRequestId (request); Logger.debug(this, () -> "intercept, Matched: " + matcherOpt.get().getId() + " request: " + request.getRequestURI()); - //fireNextStep(request, response); + fireNext(request, response, matcherOpt.get()); } } return Result.NEXT; } + private void addRequestId(final HttpServletRequest request) { + if (null == request.getAttribute("requestId")) { + request.setAttribute("requestId", UUIDUtil.uuid()); + } + } + @Override public boolean afterIntercept(final HttpServletRequest request, final HttpServletResponse response) { @@ -86,8 +96,9 @@ public boolean afterIntercept(final HttpServletRequest request, final HttpServle final Optional matcherOpt = this.anyMatcher(request, response, RequestMatcher::runAfterRequest); if (matcherOpt.isPresent()) { + addRequestId (request); Logger.debug(this, () -> "afterIntercept, Matched: " + matcherOpt.get().getId() + " request: " + request.getRequestURI()); - //fireNextStep(request, response); + fireNext(request, response, matcherOpt.get()); } } @@ -102,4 +113,19 @@ private Optional anyMatcher(final HttpServletRequest request, fi .findFirst(); } + /** + * Since the Fire the next step on the Analytics pipeline + * @param request + * @param response + * @param requestMatcher + */ + protected void fireNext(final HttpServletRequest request, final HttpServletResponse response, + final RequestMatcher requestMatcher) { + + Logger.debug(this, ()-> "fireNext, uri: " + request.getRequestURI() + + " requestMatcher: " + requestMatcher.getId()); + WebEventsCollectorServiceFactory.getInstance().getWebEventsCollectorService().fireCollectors(request, response, requestMatcher); + } + + } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java new file mode 100644 index 00000000000..7dcd96bf15a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java @@ -0,0 +1,76 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.VanitiesRequestMatcher; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotcms.visitor.filter.characteristics.BaseCharacter; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.filters.CMSFilter; +import com.dotmarketing.filters.Constants; +import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.util.UtilMethods; +import io.vavr.control.Try; + +import java.util.HashMap; +import java.util.Map; + +/** + * This asynchronized collector collects the page/asset information based on the vanity URL previous loaded on the + * @author jsanca + */ +public class AsyncVanitiesCollector implements Collector { + + private final HostAPI hostAPI; + private final Map match = new HashMap<>(); + + public AsyncVanitiesCollector() { + this(APILocator.getHostAPI()); + } + + public AsyncVanitiesCollector(final HostAPI hostAPI) { + + this.hostAPI = hostAPI; + + match.put(CMSFilter.IAm.PAGE, new PagesCollector()); + match.put(CMSFilter.IAm.FILE, new FilesCollector()); + } + + @Override + public boolean test(CollectorContextMap collectorContextMap) { + final CachedVanityUrl cachedVanityUrl = (CachedVanityUrl)collectorContextMap.get(Constants.VANITY_URL_OBJECT); + + return VanitiesRequestMatcher.VANITIES_MATCHER_ID.equals(collectorContextMap.getRequestMatcher().getId()) && + UtilMethods.isSet(cachedVanityUrl) && cachedVanityUrl.isForward(); + } + + + @Override + public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean) { + + // this will be a new event + final CachedVanityUrl cachedVanityUrl = (CachedVanityUrl) collectorContextMap.get(Constants.VANITY_URL_OBJECT); + + final Host currentHost = (Host)collectorContextMap.get("currentHost"); + final Long languageId = (Long)collectorContextMap.get("langId"); + + final Host site = Try.of(()->this.hostAPI.find(currentHost.getIdentifier(), APILocator.systemUser(), + false)).get(); + final CMSFilter.IAm whoIAM = BaseCharacter.resolveResourceType(cachedVanityUrl.forwardTo, site, languageId); + + if (UtilMethods.isSet(whoIAM)) { + + final CollectorContextMap innerCollectorContextMap = new WrapperCollectorContextMap(collectorContextMap, + Map.of("uri", cachedVanityUrl.forwardTo)); + match.get(whoIAM).collect(innerCollectorContextMap, collectorPayloadBean); + } + + collectorPayloadBean.put("comeFromVanityURL", "true"); + return collectorPayloadBean; + } + + @Override + public boolean isAsync() { + return true; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java new file mode 100644 index 00000000000..3ab4a74277e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java @@ -0,0 +1,56 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.util.FunctionUtils; +import com.dotmarketing.business.APILocator; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +public class BasicProfileCollector implements Collector { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"); + @Override + public boolean test(CollectorContextMap collectorContextMap) { + + return true; // every one needs a basic profile + } + + @Override + public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean) { + + final String requestId = (String)collectorContextMap.get("requestId"); + final Long time = (Long)collectorContextMap.get("time"); + final String clusterId = (String)collectorContextMap.get("cluster"); + final String serverId = (String)collectorContextMap.get("server"); + final String sessionId = (String)collectorContextMap.get("session"); + final Boolean sessionNew = (Boolean)collectorContextMap.get("sessionNew"); + + final Long timestamp = FunctionUtils.getOrDefault(Objects.nonNull(time), () -> time, System::currentTimeMillis); + final Instant instant = Instant.ofEpochMilli(timestamp); + final ZonedDateTime zonedDateTimeUTC = instant.atZone(ZoneId.of("UTC")); + + collectorPayloadBean.put("request_id", requestId); + collectorPayloadBean.put("utc_time", FORMATTER.format(zonedDateTimeUTC)); + collectorPayloadBean.put("cluster", + FunctionUtils.getOrDefault(Objects.nonNull(clusterId), ()->clusterId, ClusterFactory::getClusterId)); + collectorPayloadBean.put("server", + FunctionUtils.getOrDefault(Objects.nonNull(serverId), ()->serverId,()->APILocator.getServerAPI().readServerId())); + collectorPayloadBean.put("sessionId", sessionId); + collectorPayloadBean.put("sessionNew", sessionNew); + return collectorPayloadBean; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public boolean isEventCreator(){ + return false; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CharacterCollectorContextMap.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CharacterCollectorContextMap.java new file mode 100644 index 00000000000..f49a5850f41 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CharacterCollectorContextMap.java @@ -0,0 +1,50 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.RequestMatcher; +import com.dotcms.visitor.filter.characteristics.Character; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * This Context Map has the character map + * @author jsanca + */ +public class CharacterCollectorContextMap implements CollectorContextMap { + + private final Map contextMap = new HashMap<>(); + private final RequestMatcher requestMatcher; + private final Map characterMap; + + public CharacterCollectorContextMap(final Character character, + final RequestMatcher requestMatcher, + final Map contextMap) { + + this.characterMap = character.getMap(); + this.requestMatcher = requestMatcher; + this.contextMap.putAll(contextMap); + } + + + + @Override + public Object get(final String key) { + + if (this.characterMap.containsKey(key)) { + return this.characterMap.get(key); + } + + if (this.contextMap.containsKey(key)) { + return this.contextMap.get(key); + } + + return null; + } + + + @Override + public RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java new file mode 100644 index 00000000000..940f000a5c2 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java @@ -0,0 +1,44 @@ +package com.dotcms.analytics.track.collectors; + +/** + * A collector command basically puts information into a collector payload bean + * @author jsanca + */ +public interface Collector { + + /** + * Test if the collector should run + * @param collectorContextMap + * @return + */ + boolean test(final CollectorContextMap collectorContextMap); + /** + * This method is called in order to fire the collector + * @param collectorContextMap + * @param collectorPayloadBean + * @return CollectionCollectorPayloadBean + */ + CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean); + + /** + * True if the collector should run async + * @return boolean + */ + default boolean isAsync() { + return false; + } + + /** + * Return an id for the Collector, by default returns the class name. + * @return + */ + default String getId() { + + return this.getClass().getName(); + } + + default boolean isEventCreator(){ + return true; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CollectorContextMap.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CollectorContextMap.java new file mode 100644 index 00000000000..bae69a850e3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CollectorContextMap.java @@ -0,0 +1,9 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.RequestMatcher; + +public interface CollectorContextMap { + + Object get(String key); + RequestMatcher getRequestMatcher(); // since we do not have the previous step phase we need to keep this as an object, but will be a RequestMatcher +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CollectorPayloadBean.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CollectorPayloadBean.java new file mode 100644 index 00000000000..12164a68544 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/CollectorPayloadBean.java @@ -0,0 +1,17 @@ +package com.dotcms.analytics.track.collectors; + +import java.io.Serializable; +import java.util.Map; + +/** + * Encapsulate the basic signature for a collector payload bean + * @author jsanca + */ +public interface CollectorPayloadBean { + + CollectorPayloadBean put(String key, Serializable value); + Serializable get(String key); + Map toMap(); + + CollectorPayloadBean add(CollectorPayloadBean other); +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/ConcurrentCollectorPayloadBean.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/ConcurrentCollectorPayloadBean.java new file mode 100644 index 00000000000..bd25853f5ef --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/ConcurrentCollectorPayloadBean.java @@ -0,0 +1,38 @@ +package com.dotcms.analytics.track.collectors; + +import java.io.Serializable; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Implements a collector payload bean that is thread safe + * @author jsanca + */ +public class ConcurrentCollectorPayloadBean implements CollectorPayloadBean { + + private final ConcurrentHashMap map = new ConcurrentHashMap<>(); + + @Override + public CollectorPayloadBean put(final String key, final Serializable value) { + if (null != value) { + map.put(key, value); + } + return this; + } + + @Override + public Serializable get(final String key) { + return map.get(key); + } + + @Override + public Map toMap() { + return Map.copyOf(map); + } + + public CollectorPayloadBean add(final CollectorPayloadBean other) { + map.putAll(other.toMap()); + return this; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/EventType.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/EventType.java new file mode 100644 index 00000000000..7035ed75fea --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/EventType.java @@ -0,0 +1,23 @@ +package com.dotcms.analytics.track.collectors; + +public enum EventType { + VANITY_REQUEST("VANITY_REQUEST"), + FILE_REQUEST("FILE_REQUEST"), + PAGE_REQUEST("PAGE_REQUEST"), + + URL_MAP("URL_MAP"); + + private final String type; + private EventType(String type) { + this.type = type; + } + + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/FilesCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/FilesCollector.java new file mode 100644 index 00000000000..9408ab91420 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/FilesCollector.java @@ -0,0 +1,102 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.FilesRequestMatcher; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.business.ContentletAPI; +import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.fileassets.business.FileAssetAPI; +import com.liferay.util.StringPool; + +import java.util.HashMap; +import java.util.Objects; +import java.util.Optional; + +/** + * This collector collects the file information + * @author jsanca + */ +public class FilesCollector implements Collector { + + private final FileAssetAPI fileAssetAPI; + private final ContentletAPI contentletAPI; + + public FilesCollector() { + this(APILocator.getFileAssetAPI(), APILocator.getContentletAPI()); + } + + public FilesCollector(final FileAssetAPI fileAssetAPI, + final ContentletAPI contentletAPI) { + + this.fileAssetAPI = fileAssetAPI; + this.contentletAPI = contentletAPI; + } + + @Override + public boolean test(CollectorContextMap collectorContextMap) { + return FilesRequestMatcher.FILES_MATCHER_ID.equals(collectorContextMap.getRequestMatcher().getId()) ; // should compare with the id + } + + + @Override + public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean) { + + final String uri = (String)collectorContextMap.get("uri"); + final String host = (String)collectorContextMap.get("host"); + final Host site = (Host) collectorContextMap.get("currentHost"); + final Long languageId = (Long)collectorContextMap.get("langId"); + final String language = (String)collectorContextMap.get("lang"); + final HashMap fileObject = new HashMap<>(); + + if (Objects.nonNull(uri) && Objects.nonNull(site) && Objects.nonNull(languageId)) { + + getFileAsset(uri, site, languageId).ifPresent(fileAsset -> { + fileObject.put("id", fileAsset.getIdentifier()); + fileObject.put("title", fileAsset.getTitle()); + fileObject.put("url", uri); + }); + } + + collectorPayloadBean.put("object", fileObject); + collectorPayloadBean.put("url", uri); + collectorPayloadBean.put("host", host); + collectorPayloadBean.put("language", language); + collectorPayloadBean.put("site", null != site?site.getIdentifier():"unknown"); + collectorPayloadBean.put("event_type", EventType.FILE_REQUEST.getType()); + + return collectorPayloadBean; + } + + private Optional getFileAsset(String uri, Host host, Long languageId) { + try { + if (uri.endsWith(".dotsass")) { + final String actualUri = uri.substring(0, uri.lastIndexOf('.')) + ".scss"; + return Optional.ofNullable(this.fileAssetAPI.getFileByPath(actualUri, host, languageId, true)); + } else if (uri.startsWith("/dA") || uri.startsWith("/contentAsset") || uri.startsWith("/dotAsset")) { + final String[] split = uri.split(StringPool.FORWARD_SLASH); + final String id = uri.startsWith("/contentAsset") ? split[3] : split[2]; + return getFileAsset(languageId, id); + } else { + return Optional.ofNullable(this.fileAssetAPI.getFileByPath(uri, host, languageId, true)); + } + } catch (DotDataException | DotSecurityException e) { + return Optional.empty(); + } + } + + private Optional getFileAsset(final Long languageId, final String id) throws DotDataException, DotSecurityException { + + return Optional.ofNullable(contentletAPI.findContentletByIdentifier(id, true, languageId, + APILocator.systemUser(), false)); + } + + @Override + public boolean isAsync() { + return true; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java new file mode 100644 index 00000000000..9fd42684caa --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java @@ -0,0 +1,112 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.Util; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.cms.urlmap.URLMapAPIImpl; +import com.dotmarketing.cms.urlmap.URLMapInfo; +import com.dotmarketing.cms.urlmap.UrlMapContext; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; +import io.vavr.control.Try; + +import java.util.HashMap; +import java.util.Objects; +import java.util.Optional; + +import static com.dotcms.exception.ExceptionUtil.getErrorMessage; +import static com.dotmarketing.util.Constants.DONT_RESPECT_FRONT_END_ROLES; + +/** + * This class collects the information of Detail Pages used to display URL Mapped content. + * + * @author Jose Castro + * @since Sep 13th, 2024 + */ +public class PageDetailCollector implements Collector { + + private final HTMLPageAssetAPI pageAPI; + private final URLMapAPIImpl urlMapAPI; + + public PageDetailCollector() { + this(APILocator.getHTMLPageAssetAPI(), APILocator.getURLMapAPI()); + } + + public PageDetailCollector(final HTMLPageAssetAPI pageAPI, URLMapAPIImpl urlMapAPI) { + this.urlMapAPI = urlMapAPI; + this.pageAPI = pageAPI; + } + + @Override + public boolean test(CollectorContextMap collectorContextMap) { + return isUrlMap(collectorContextMap); + } + + @Override + public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean) { + + final String uri = (String) collectorContextMap.get("uri"); + final Host site = (Host) collectorContextMap.get("currentHost"); + final Long languageId = (Long) collectorContextMap.get("langId"); + final PageMode pageMode = (PageMode) collectorContextMap.get("pageMode"); + final String language = (String)collectorContextMap.get("lang"); + + final UrlMapContext urlMapContext = new UrlMapContext( + pageMode, languageId, uri, site, APILocator.systemUser()); + + final Optional urlMappedContent = + Try.of(() -> this.urlMapAPI.processURLMap(urlMapContext)).get(); + + if (urlMappedContent.isPresent()) { + final URLMapInfo urlMapInfo = urlMappedContent.get(); + final Contentlet urlMapContentlet = urlMapInfo.getContentlet(); + final ContentType urlMapContentType = urlMapContentlet.getContentType(); + + final IHTMLPage detailPageContent = Try.of(() -> + this.pageAPI.findByIdLanguageFallback(urlMapContentType.detailPage(), languageId, true, APILocator.systemUser(), DONT_RESPECT_FRONT_END_ROLES)) + .onFailure(e -> Logger.error(this, String.format("Error finding detail page " + + "'%s': %s", urlMapContentType.detailPage(), getErrorMessage(e)), e)) + .getOrNull(); + + final HashMap pageObject = new HashMap<>(); + pageObject.put("id", detailPageContent.getIdentifier()); + pageObject.put("title", detailPageContent.getTitle()); + pageObject.put("url", uri); + pageObject.put("detail_page_url", urlMapContentType.detailPage()); + collectorPayloadBean.put("object", pageObject); + } + + collectorPayloadBean.put("event_type", EventType.PAGE_REQUEST.getType()); + collectorPayloadBean.put("url", uri); + collectorPayloadBean.put("language", language); + + if (Objects.nonNull(site)) { + collectorPayloadBean.put("host", site.getIdentifier()); + } + return collectorPayloadBean; + } + + private boolean isUrlMap(final CollectorContextMap collectorContextMap){ + + final String uri = (String)collectorContextMap.get("uri"); + final Long languageId = (Long)collectorContextMap.get("langId"); + final PageMode pageMode = (PageMode)collectorContextMap.get("pageMode"); + final Host site = (Host) collectorContextMap.get("currentHost"); + + final UrlMapContext urlMapContext = new UrlMapContext( + pageMode, languageId, uri, site, APILocator.systemUser()); + + return Util.isUrlMap(urlMapContext); + } + + @Override + public boolean isAsync() { + return true; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PagesCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PagesCollector.java new file mode 100644 index 00000000000..ce2f62c642e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PagesCollector.java @@ -0,0 +1,119 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.Util; +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.cms.urlmap.URLMapAPIImpl; +import com.dotmarketing.cms.urlmap.URLMapInfo; +import com.dotmarketing.cms.urlmap.UrlMapContext; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.htmlpageasset.business.HTMLPageAssetAPI; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; +import com.dotmarketing.util.PageMode; +import io.vavr.control.Try; + +import java.util.HashMap; +import java.util.Objects; +import java.util.Optional; + + +/** + * This collector collects the page information + * @author jsanca + */ +public class PagesCollector implements Collector { + + private final HTMLPageAssetAPI pageAPI; + private final URLMapAPIImpl urlMapAPI; + + public PagesCollector() { + this(APILocator.getHTMLPageAssetAPI(), APILocator.getURLMapAPI()); + } + + public PagesCollector(final HTMLPageAssetAPI pageAPI, + final URLMapAPIImpl urlMapAPI) { + this.pageAPI = pageAPI; + this.urlMapAPI = urlMapAPI; + } + + @Override + public boolean test(CollectorContextMap collectorContextMap) { + return PagesAndUrlMapsRequestMatcher.PAGES_AND_URL_MAPS_MATCHER_ID.equals(collectorContextMap.getRequestMatcher().getId()); + } + + @Override + public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean) { + + final String uri = (String)collectorContextMap.get("uri"); + final Host site = (Host) collectorContextMap.get("currentHost"); + final Long languageId = (Long)collectorContextMap.get("langId"); + final String language = (String)collectorContextMap.get("lang"); + final PageMode pageMode = (PageMode)collectorContextMap.get("pageMode"); + final HashMap pageObject = new HashMap<>(); + + if (Objects.nonNull(uri) && Objects.nonNull(site) && Objects.nonNull(languageId)) { + + final boolean isUrlMap = isUrlMap(collectorContextMap); + + if (isUrlMap) { + + final UrlMapContext urlMapContext = new UrlMapContext( + pageMode, languageId, uri, site, APILocator.systemUser()); + + final Optional urlMappedContent = + Try.of(() -> this.urlMapAPI.processURLMap(urlMapContext)).get(); + + if (urlMappedContent.isPresent()) { + final URLMapInfo urlMapInfo = urlMappedContent.get(); + final Contentlet urlMapContentlet = urlMapInfo.getContentlet(); + final ContentType urlMapContentType = urlMapContentlet.getContentType(); + pageObject.put("id", urlMapContentlet.getIdentifier()); + pageObject.put("title", urlMapContentlet.getTitle()); + pageObject.put("content_type_id", urlMapContentType.id()); + pageObject.put("content_type_name", urlMapContentType.name()); + pageObject.put("content_type_var_name", urlMapContentType.variable()); + collectorPayloadBean.put("event_type", EventType.URL_MAP.getType()); + } + } else { + final IHTMLPage page = Try.of(() -> + this.pageAPI.getPageByPath(uri, site, languageId, true)).get(); + pageObject.put("id", page.getIdentifier()); + pageObject.put("title", page.getTitle()); + collectorPayloadBean.put("event_type", EventType.PAGE_REQUEST.getType()); + } + pageObject.put("url", uri); + } + + collectorPayloadBean.put("object", pageObject); + collectorPayloadBean.put("url", uri); + collectorPayloadBean.put("language", language); + + if (Objects.nonNull(site)) { + collectorPayloadBean.put("host", site.getIdentifier()); + } + + return collectorPayloadBean; + } + + private boolean isUrlMap(final CollectorContextMap collectorContextMap){ + + final String uri = (String)collectorContextMap.get("uri"); + final Long languageId = (Long)collectorContextMap.get("langId"); + final PageMode pageMode = (PageMode)collectorContextMap.get("pageMode"); + final Host currentHost = (Host) collectorContextMap.get("currentHost"); + + final UrlMapContext urlMapContext = new UrlMapContext( + pageMode, languageId, uri, currentHost, APILocator.systemUser()); + + return Util.isUrlMap(urlMapContext); + } + + @Override + public boolean isAsync() { + return true; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/RequestCharacterCollectorContextMap.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/RequestCharacterCollectorContextMap.java new file mode 100644 index 00000000000..46ebe9ebac2 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/RequestCharacterCollectorContextMap.java @@ -0,0 +1,61 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.RequestMatcher; +import com.dotcms.visitor.filter.characteristics.Character; + +import com.dotmarketing.business.web.WebAPILocator; + +import javax.servlet.http.HttpServletRequest; + +/** + * This Context Map has the request + character map + * @author jsanca + */ +public class RequestCharacterCollectorContextMap implements CollectorContextMap { + + private final RequestMatcher requestMatcher; + private final Character character; + final HttpServletRequest request; + + public RequestCharacterCollectorContextMap(final HttpServletRequest request, + final Character character, + final RequestMatcher requestMatcher) { + this.request = request; + this.character = character; + this.requestMatcher = requestMatcher; + } + + + + @Override + public Object get(final String key) { + + if (request.getParameter(key) != null) { + return request.getParameter(key); + } + + if(request.getAttribute(key) != null) { + return request.getAttribute(key); + } + + if (this.character.getMap().containsKey(key)) { + return this.character.getMap().get(key); + } + + if("request".equals(key)) { + return request; + } + + if (key.equals("currentHost")) { + return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + } + + return null; + } + + + @Override + public RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java new file mode 100644 index 00000000000..f25cff71be0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java @@ -0,0 +1,80 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.VanitiesRequestMatcher; +import com.dotcms.vanityurl.filters.VanityUrlRequestWrapper; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotmarketing.beans.Host; +import com.dotmarketing.filters.Constants; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Objects; + +/** + * This synchronized collector that collects the vanities + * @author jsanca + */ +public class SyncVanitiesCollector implements Collector { + + + public static final String VANITY_URL_KEY = "vanity_url"; + + public SyncVanitiesCollector() { + } + + @Override + public boolean test(CollectorContextMap collectorContextMap) { + return VanitiesRequestMatcher.VANITIES_MATCHER_ID.equals(collectorContextMap.getRequestMatcher().getId()) ; // should compare with the id + } + + + @Override + public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap, + final CollectorPayloadBean collectorPayloadBean) { + + if (null != collectorContextMap.get("request")) { + + final HttpServletRequest request = (HttpServletRequest)collectorContextMap.get("request"); + final String vanityUrl = (String)request.getAttribute(Constants.CMS_FILTER_URI_OVERRIDE); + final String vanityQueryString = (String)request.getAttribute(Constants.CMS_FILTER_QUERY_STRING_OVERRIDE); + if (request instanceof VanityUrlRequestWrapper) { + final VanityUrlRequestWrapper vanityRequest = (VanityUrlRequestWrapper) request; + collectorPayloadBean.put("response_code", vanityRequest.getResponseCode()); + } + + collectorPayloadBean.put(VANITY_URL_KEY, vanityUrl); + collectorPayloadBean.put("vanity_query_string", vanityQueryString); + } + + final String uri = (String)collectorContextMap.get("uri"); + final Host site = (Host) collectorContextMap.get("currentHost"); + final Long languageId = (Long)collectorContextMap.get("langId"); + final String language = (String)collectorContextMap.get("lang"); + final CachedVanityUrl cachedVanityUrl = (CachedVanityUrl)collectorContextMap.get(Constants.VANITY_URL_OBJECT); + final HashMap vanityObject = new HashMap<>(); + + if (Objects.nonNull(cachedVanityUrl)) { + + vanityObject.put("id", cachedVanityUrl.vanityUrlId); + vanityObject.put("forward_to", + collectorPayloadBean.get(VANITY_URL_KEY)!=null?(String)collectorPayloadBean.get(VANITY_URL_KEY):cachedVanityUrl.forwardTo); + vanityObject.put("url", uri); + vanityObject.put("response", String.valueOf(cachedVanityUrl.response)); + } + + collectorPayloadBean.put("object", vanityObject); + collectorPayloadBean.put("url", uri); + collectorPayloadBean.put("language", language); + collectorPayloadBean.put("language_id", languageId); + collectorPayloadBean.put("site", site.getIdentifier()); + collectorPayloadBean.put("event_type", EventType.VANITY_REQUEST.getType()); + + return collectorPayloadBean; + } + + @Override + public boolean isAsync() { + return false; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java new file mode 100644 index 00000000000..388256b7bd0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java @@ -0,0 +1,29 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.RequestMatcher; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * This class is in charge of firing the collectors to populate the event payload. Also do the triggering the event to save the analytics data + * @author jsanca + */ +public interface WebEventsCollectorService { + + void fireCollectors (final HttpServletRequest request, final HttpServletResponse response, + final RequestMatcher requestMatcher); + + + /** + * Add a collector + * @param collectors + */ + void addCollector(final Collector... collectors); + + /** + * Remove a collector by id + * @param collectorId + */ + void removeCollector(final String collectorId); +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java new file mode 100644 index 00000000000..00054371f09 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java @@ -0,0 +1,198 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.RequestMatcher; +import com.dotcms.jitsu.EventLogRunnable; +import com.dotcms.jitsu.EventLogSubmitter; +import com.dotcms.visitor.filter.characteristics.Character; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.web.WebAPILocator; +import com.dotmarketing.filters.Constants; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +/** + * This class provides the default implementation for the WebEventsCollectorService + * + * @author jsanca + */ +public class WebEventsCollectorServiceFactory { + + private WebEventsCollectorServiceFactory () {} + + private final WebEventsCollectorService webEventsCollectorService = new WebEventsCollectorServiceImpl(); + + private static class SingletonHolder { + private static final WebEventsCollectorServiceFactory INSTANCE = new WebEventsCollectorServiceFactory(); + } + + + /** + * Get the instance. + * @return WebEventsCollectorServiceFactory + */ + public static WebEventsCollectorServiceFactory getInstance() { + + return WebEventsCollectorServiceFactory.SingletonHolder.INSTANCE; + } // getInstance. + + + public WebEventsCollectorService getWebEventsCollectorService() { + + return webEventsCollectorService; + } + + private static class WebEventsCollectorServiceImpl implements WebEventsCollectorService { + + private final Collectors baseCollectors = new Collectors(); + private final Collectors eventCreatorCollectors = new Collectors(); + + private final EventLogSubmitter submitter = new EventLogSubmitter(); + + WebEventsCollectorServiceImpl () { + + addCollector(new BasicProfileCollector(), new FilesCollector(), new PagesCollector(), + new PageDetailCollector(), new SyncVanitiesCollector(), new AsyncVanitiesCollector()); + } + + @Override + public void fireCollectors(final HttpServletRequest request, + final HttpServletResponse response, + final RequestMatcher requestMatcher) { + + if (!baseCollectors.isEmpty() || !eventCreatorCollectors.isEmpty()) { + + this.fireCollectorsAndEmitEvent(request, response, requestMatcher); + } else { + + Logger.debug(this, ()-> "No collectors to run"); + } + } + + private void fireCollectorsAndEmitEvent(final HttpServletRequest request, + final HttpServletResponse response, + final RequestMatcher requestMatcher) { + + final Character character = WebAPILocator.getCharacterWebAPI().getOrCreateCharacter(request, response); + final Host site = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + + final CollectorPayloadBean base = new ConcurrentCollectorPayloadBean(); + final CollectorContextMap syncCollectorContextMap = + new RequestCharacterCollectorContextMap(request, character, requestMatcher); + + Logger.debug(this, ()-> "Running sync collectors"); + + collect(baseCollectors.syncCollectors.values(), base, syncCollectorContextMap); + final List futureEvents = getFutureEvents(eventCreatorCollectors.syncCollectors.values(), + syncCollectorContextMap); + + + // if there is anything to run async + final PageMode pageMode = PageMode.get(request); + final CollectorContextMap collectorContextMap = new CharacterCollectorContextMap(character, requestMatcher, + getCollectorContextMap(request, pageMode, site)); + + try { + this.submitter.logEvent( + new EventLogRunnable(site, () -> { + Logger.debug(this, () -> "Running async collectors"); + + collect(baseCollectors.asyncCollectors.values(), base, collectorContextMap); + final List asyncFutureEvents = getFutureEvents( + eventCreatorCollectors.asyncCollectors.values(), collectorContextMap); + + return Stream.concat(futureEvents.stream(), asyncFutureEvents.stream()) + .map(payload -> payload.add(base)) + .map(CollectorPayloadBean::toMap) + .collect(java.util.stream.Collectors.toList()); + })); + } catch (Exception e) { + Logger.debug(WebEventsCollectorServiceFactory.class, () -> "Error saving Analitycs Events:" + e.getMessage()); + } + } + + private static Map getCollectorContextMap(final HttpServletRequest request, + final PageMode pageMode, final Host site) { + final Map contextMap = new HashMap<>(Map.of("uri", request.getRequestURI(), + "pageMode", pageMode, + "currentHost", site, + "requestId", request.getAttribute("requestId"))); + + if (Objects.nonNull(request.getAttribute(Constants.VANITY_URL_OBJECT))) { + contextMap.put(Constants.VANITY_URL_OBJECT, request.getAttribute(Constants.VANITY_URL_OBJECT)); + } + + return contextMap; + } + + private List getFutureEvents(final Collection eventCreators, + final CollectorContextMap collectorContextMap) { + return eventCreators.stream() + .filter(collector -> collector.test(collectorContextMap)) + .map(collector -> { + final CollectorPayloadBean futureEvent = new ConcurrentCollectorPayloadBean(); + collector.collect(collectorContextMap, futureEvent); + return futureEvent; + }).collect(java.util.stream.Collectors.toList()); + } + + private void collect(final Collection collectors, + final CollectorPayloadBean payload, + final CollectorContextMap syncCollectorContextMap) { + collectors.stream() + .filter(collector -> collector.test(syncCollectorContextMap)) + .forEach(collector -> collector.collect(syncCollectorContextMap, payload)); + } + + @Override + public void addCollector(final Collector... collectors) { + for (final Collector collector : collectors) { + if (collector.isEventCreator()) { + eventCreatorCollectors.add(collector); + } else { + baseCollectors.add(collector); + } + } + } + + @Override + public void removeCollector(final String collectorId) { + eventCreatorCollectors.remove(collectorId); + baseCollectors.remove(collectorId); + } + + private static class Collectors { + private final Map syncCollectors = new ConcurrentHashMap<>(); + private final Map asyncCollectors = new ConcurrentHashMap<>(); + + public void add(final Collector... collectors){ + for (final Collector collector : collectors) { + if (collector.isAsync()) { + asyncCollectors.put(collector.getId(), collector); + } else { + syncCollectors.put(collector.getId(), collector); + } + } + } + + public void remove(String collectorId) { + asyncCollectors.remove(collectorId); + syncCollectors.remove(collectorId); + } + + public boolean isEmpty() { + return asyncCollectors.isEmpty() && !syncCollectors.isEmpty(); + } + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WrapperCollectorContextMap.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WrapperCollectorContextMap.java new file mode 100644 index 00000000000..1d0b8f68ebe --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WrapperCollectorContextMap.java @@ -0,0 +1,33 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.RequestMatcher; + +import java.util.Map; + +/** + * Represent a Wrapper of a {@link CollectorContextMap}, it allows override some of + * the attribute from the original {@link CollectorContextMap} + */ +public class WrapperCollectorContextMap implements CollectorContextMap { + private final CollectorContextMap collectorContextMap; + + private final Map toOverride; + + public WrapperCollectorContextMap(final CollectorContextMap collectorContextMap, + final Map toOverride){ + + this.collectorContextMap = collectorContextMap; + this.toOverride = toOverride; + } + + @Override + public Object get(String key) { + + return toOverride.containsKey(key) ? toOverride.get(key) : collectorContextMap.get(key); + } + + @Override + public RequestMatcher getRequestMatcher() { + return this.collectorContextMap.getRequestMatcher(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/FilesRequestMatcher.java b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/FilesRequestMatcher.java similarity index 87% rename from dotCMS/src/main/java/com/dotcms/analytics/track/FilesRequestMatcher.java rename to dotCMS/src/main/java/com/dotcms/analytics/track/matchers/FilesRequestMatcher.java index d5067670615..a18b01f14f0 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/FilesRequestMatcher.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/FilesRequestMatcher.java @@ -1,4 +1,4 @@ -package com.dotcms.analytics.track; +package com.dotcms.analytics.track.matchers; import com.dotcms.visitor.filter.characteristics.Character; import com.dotcms.visitor.filter.characteristics.CharacterWebAPI; @@ -15,6 +15,7 @@ */ public class FilesRequestMatcher implements RequestMatcher { + public static final String FILES_MATCHER_ID = "filesMatcher"; private final CharacterWebAPI characterWebAPI; public FilesRequestMatcher() { @@ -45,4 +46,9 @@ public boolean match(final HttpServletRequest request, final HttpServletResponse return false; } + + @Override + public String getId() { + return FILES_MATCHER_ID; + } } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/PagesAndUrlMapsRequestMatcher.java b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/PagesAndUrlMapsRequestMatcher.java similarity index 86% rename from dotCMS/src/main/java/com/dotcms/analytics/track/PagesAndUrlMapsRequestMatcher.java rename to dotCMS/src/main/java/com/dotcms/analytics/track/matchers/PagesAndUrlMapsRequestMatcher.java index df1e24b5c64..374ddaab70d 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/PagesAndUrlMapsRequestMatcher.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/PagesAndUrlMapsRequestMatcher.java @@ -1,4 +1,4 @@ -package com.dotcms.analytics.track; +package com.dotcms.analytics.track.matchers; import com.dotcms.visitor.filter.characteristics.Character; import com.dotcms.visitor.filter.characteristics.CharacterWebAPI; @@ -15,6 +15,7 @@ */ public class PagesAndUrlMapsRequestMatcher implements RequestMatcher { + public static final String PAGES_AND_URL_MAPS_MATCHER_ID = "pagesAndUrlMapsMatcher"; private final CharacterWebAPI characterWebAPI; public PagesAndUrlMapsRequestMatcher() { @@ -45,4 +46,9 @@ public boolean match(final HttpServletRequest request, final HttpServletResponse return false; } + + @Override + public String getId() { + return PAGES_AND_URL_MAPS_MATCHER_ID; + } } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/RequestMatcher.java b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/RequestMatcher.java similarity index 98% rename from dotCMS/src/main/java/com/dotcms/analytics/track/RequestMatcher.java rename to dotCMS/src/main/java/com/dotcms/analytics/track/matchers/RequestMatcher.java index 0cbd764d1c3..022dee6ed98 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/RequestMatcher.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/RequestMatcher.java @@ -1,4 +1,4 @@ -package com.dotcms.analytics.track; +package com.dotcms.analytics.track.matchers; import com.dotmarketing.util.Config; import com.dotmarketing.util.RegEX; diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/RulesRedirectsRequestMatcher.java b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/RulesRedirectsRequestMatcher.java similarity index 77% rename from dotCMS/src/main/java/com/dotcms/analytics/track/RulesRedirectsRequestMatcher.java rename to dotCMS/src/main/java/com/dotcms/analytics/track/matchers/RulesRedirectsRequestMatcher.java index a32c4badccf..f150ea3af46 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/RulesRedirectsRequestMatcher.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/RulesRedirectsRequestMatcher.java @@ -1,4 +1,4 @@ -package com.dotcms.analytics.track; +package com.dotcms.analytics.track.matchers; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -10,6 +10,8 @@ */ public class RulesRedirectsRequestMatcher implements RequestMatcher { + public static final String RULES_MATCHER_ID = "rules"; + @Override public boolean runAfterRequest() { return true; @@ -21,4 +23,9 @@ public boolean match(final HttpServletRequest request, final HttpServletResponse final String ruleRedirect = response.getHeader("X-DOT-SendRedirectRuleAction"); return Objects.nonNull(ruleRedirect) && "true".equals(ruleRedirect); } + + @Override + public String getId() { + return RULES_MATCHER_ID; + } } diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/VanitiesRequestMatcher.java b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/VanitiesRequestMatcher.java similarity index 76% rename from dotCMS/src/main/java/com/dotcms/analytics/track/VanitiesRequestMatcher.java rename to dotCMS/src/main/java/com/dotcms/analytics/track/matchers/VanitiesRequestMatcher.java index 433d8a2853d..df7be86e702 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/VanitiesRequestMatcher.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/matchers/VanitiesRequestMatcher.java @@ -1,4 +1,4 @@ -package com.dotcms.analytics.track; +package com.dotcms.analytics.track.matchers; import com.dotmarketing.filters.Constants; @@ -12,6 +12,8 @@ */ public class VanitiesRequestMatcher implements RequestMatcher { + public static final String VANITIES_MATCHER_ID = "rules"; + @Override public boolean runAfterRequest() { return true; @@ -24,4 +26,9 @@ public boolean match(final HttpServletRequest request, final HttpServletResponse return Objects.nonNull(vanityHasRun); } + + @Override + public String getId() { + return VANITIES_MATCHER_ID; + } } diff --git a/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java b/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java index 68152ed7fca..882eba39695 100644 --- a/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java +++ b/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java @@ -169,6 +169,26 @@ public OrderItem[] orders() { return orders; } + public String[] dimensions() { + return dimensions; + } + + public String[] measures() { + return measures; + } + + public long limit() { + return limit; + } + + public long offset() { + return offset; + } + + public TimeDimension[] timeDimensions() { + return timeDimensions; + } + public CubeJSQuery.Builder builder() { final Builder builder = new Builder() .dimensions(dimensions) @@ -280,12 +300,12 @@ private static List getEmptyIfIsNotSet(T[] array) { return UtilMethods.isSet(array) ? Arrays.asList(array) : Collections.emptyList(); } - private Builder dimensions(final Collection dimensions) { + public Builder dimensions(final Collection dimensions) { this.dimensions = dimensions.toArray(new String[dimensions.size()]); return this; } - private Builder measures(final Collection measures) { + public Builder measures(final Collection measures) { this.measures = measures.toArray(new String[measures.size()]); return this; } @@ -370,7 +390,7 @@ public Builder timeDimensions(Collection timeDimensions) { } } - static class TimeDimension { + public static class TimeDimension { String dimension; String granularity; @@ -388,7 +408,7 @@ public String getGranularity() { } } - static class OrderItem { + public static class OrderItem { private String orderBy; private Order order; diff --git a/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSetImpl.java b/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSetImpl.java index 9bf87325543..7d07aaa1ab5 100644 --- a/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSetImpl.java +++ b/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSetImpl.java @@ -1,12 +1,8 @@ package com.dotcms.cube; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.Spliterator; -import java.util.function.Consumer; import java.util.stream.Collectors; /** diff --git a/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java b/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java index 065de6ebaaf..e31cef6e2ed 100644 --- a/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java +++ b/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java @@ -114,6 +114,11 @@ public Builder add(final String member, Operator operator, final String... value return this; } + public Builder add(final SimpleFilter filter){ + filters.add(filter); + return this; + } + public Builder add(final LogicalFilter logicalFilter){ filters.add(logicalFilter); return this; diff --git a/dotCMS/src/main/java/com/dotcms/jitsu/AnalyticsEventsPayload.java b/dotCMS/src/main/java/com/dotcms/jitsu/AnalyticsEventsPayload.java new file mode 100644 index 00000000000..e6d65aff50d --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jitsu/AnalyticsEventsPayload.java @@ -0,0 +1,36 @@ +package com.dotcms.jitsu; + +import com.dotmarketing.util.json.JSONObject; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * This class is used only to override an undesired behavior in the dotCMS codebase which is based for experiemnts + * @author jsanca + */ +public class AnalyticsEventsPayload extends EventsPayload { + + final List> payload; + + public AnalyticsEventsPayload(final List> payload) { + super(Map.of()); // by now we run empty this + this.payload = payload; + } + + @Override + public Iterable payloads() { + + final List eventPayloads = new ArrayList<>(); + + for (final var eventMap : payload) { + + eventPayloads.add(new EventPayload(new JSONObject(eventMap))); + } + + return eventPayloads; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java b/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java index 268cfc30bbf..68246893593 100644 --- a/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java +++ b/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java @@ -18,8 +18,11 @@ import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; +import java.io.Serializable; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Supplier; /** * POSTs events to established endpoint in EVENT_LOG_POSTING_URL config property using the token set in @@ -31,7 +34,7 @@ public class EventLogRunnable implements Runnable { HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); private final AnalyticsApp analyticsApp; - private final EventsPayload eventPayload; + private final Supplier eventPayload; @VisibleForTesting public EventLogRunnable(final Host host) { @@ -51,7 +54,27 @@ public EventLogRunnable(final Host host, final EventsPayload eventPayload) { "Analytics key is missing, cannot log event without a key to identify data with"); } - this.eventPayload = eventPayload; + this.eventPayload = ()->eventPayload; + } + + public EventLogRunnable(final Host site, final Supplier>> payloadSupplier) { + analyticsApp = AnalyticsHelper.get().appFromHost(site); + + if (StringUtils.isBlank(analyticsApp.getAnalyticsProperties().analyticsWriteUrl())) { + throw new IllegalStateException("Event log URL is missing, cannot log event to an unknown URL"); + } + + if (StringUtils.isBlank(analyticsApp.getAnalyticsProperties().analyticsKey())) { + throw new IllegalStateException( + "Analytics key is missing, cannot log event without a key to identify data with"); + } + + this.eventPayload = ()-> convertToEventPayload(payloadSupplier.get()); + } + + private EventsPayload convertToEventPayload(final List> listStringSerializableMap) { + + return new AnalyticsEventsPayload(listStringSerializableMap); } @Override @@ -60,7 +83,7 @@ public void run() { final String url = analyticsApp.getAnalyticsProperties().analyticsWriteUrl(); final CircuitBreakerUrlBuilder builder = getCircuitBreakerUrlBuilder(url); - for (EventPayload payload : eventPayload.payloads()) { + for (EventPayload payload : eventPayload.get().payloads()) { sendEvent(builder, payload).ifPresent(response -> { if (response.getStatusCode() != HttpStatus.SC_OK) { @@ -75,7 +98,6 @@ public void run() { } }); } - } diff --git a/dotCMS/src/main/java/com/dotcms/jitsu/EventLogSubmitter.java b/dotCMS/src/main/java/com/dotcms/jitsu/EventLogSubmitter.java index 1b6e2733563..810484dab40 100644 --- a/dotCMS/src/main/java/com/dotcms/jitsu/EventLogSubmitter.java +++ b/dotCMS/src/main/java/com/dotcms/jitsu/EventLogSubmitter.java @@ -36,4 +36,11 @@ public void logEvent(final Host host, final EventsPayload eventPayload) { .execute(new EventLogRunnable(host, eventPayload)); } + public void logEvent(final EventLogRunnable runnable) { + DotConcurrentFactory + .getInstance() + .getSubmitter("event-log-posting", submitterConfig) + .execute(runnable); + } + } diff --git a/dotCMS/src/main/java/com/dotcms/jitsu/EventsPayload.java b/dotCMS/src/main/java/com/dotcms/jitsu/EventsPayload.java index 10817ded19b..833eb6cf53f 100644 --- a/dotCMS/src/main/java/com/dotcms/jitsu/EventsPayload.java +++ b/dotCMS/src/main/java/com/dotcms/jitsu/EventsPayload.java @@ -14,7 +14,7 @@ * @see EventLogRunnable */ public class EventsPayload { - private JSONObject jsonObject; + protected JSONObject jsonObject; final List shortExperiments = new ArrayList<>(); public EventsPayload(final Map payload) { diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueConfig.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueConfig.java new file mode 100644 index 00000000000..cdfe2b29d28 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueConfig.java @@ -0,0 +1,31 @@ +package com.dotcms.jobs.business.api; + +/** + * This class represents the configuration for the Job Queue system. + */ +public class JobQueueConfig { + + /** + * The number of threads to use for job processing. + */ + private final int threadPoolSize; + + /** + * Constructs a new JobQueueConfig + * + * @param threadPoolSize The number of threads to use for job processing. + */ + public JobQueueConfig(int threadPoolSize) { + this.threadPoolSize = threadPoolSize; + } + + /** + * Gets the thread pool size for job processing. + * + * @return The number of threads to use for job processing. + */ + public int getThreadPoolSize() { + return threadPoolSize; + } + +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueConfigProducer.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueConfigProducer.java new file mode 100644 index 00000000000..4d3710bc51f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueConfigProducer.java @@ -0,0 +1,30 @@ +package com.dotcms.jobs.business.api; + +import com.dotmarketing.util.Config; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Produces; + +/** + * This class is responsible for producing the configuration for the Job Queue system. It is + * application-scoped, meaning a single instance is created for the entire application. + */ +@ApplicationScoped +public class JobQueueConfigProducer { + + // The number of threads to use for job processing. + static final int DEFAULT_THREAD_POOL_SIZE = Config.getIntProperty( + "JOB_QUEUE_THREAD_POOL_SIZE", 10 + ); + + /** + * Produces a JobQueueConfig object. This method is called by the CDI container to create a + * JobQueueConfig instance when it is necessary for dependency injection. + * + * @return A new JobQueueConfig instance + */ + @Produces + public JobQueueConfig produceJobQueueConfig() { + return new JobQueueConfig(DEFAULT_THREAD_POOL_SIZE); + } + +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java index bdbcb9e96fa..8bd0c59ff82 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java @@ -109,10 +109,18 @@ String createJob(String queueName, Map parameters) void setRetryStrategy(String queueName, RetryStrategy retryStrategy); /** - * Retrieves the CircuitBreaker instance. - * * @return The CircuitBreaker instance */ CircuitBreaker getCircuitBreaker(); + /** + * @return The size of the thread pool + */ + int getThreadPoolSize(); + + /** + * @return The default retry strategy + */ + RetryStrategy getDefaultRetryStrategy(); + } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java index e6176128c93..afcf74f3e2d 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java @@ -2,7 +2,6 @@ import com.dotcms.jobs.business.error.CircuitBreaker; import com.dotcms.jobs.business.error.ErrorDetail; -import com.dotcms.jobs.business.error.ExponentialBackoffRetryStrategy; import com.dotcms.jobs.business.error.JobCancellationException; import com.dotcms.jobs.business.error.ProcessorNotFoundException; import com.dotcms.jobs.business.error.RetryStrategy; @@ -11,7 +10,6 @@ import com.dotcms.jobs.business.processor.JobProcessor; import com.dotcms.jobs.business.processor.ProgressTracker; import com.dotcms.jobs.business.queue.JobQueue; -import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; import com.google.common.annotations.VisibleForTesting; import java.time.LocalDateTime; @@ -26,6 +24,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; /** * Manages the processing of jobs in a distributed job queue system. This class is responsible for @@ -68,6 +68,7 @@ * } * } */ +@ApplicationScoped public class JobQueueManagerAPIImpl implements JobQueueManagerAPI { private final AtomicBoolean isStarted = new AtomicBoolean(false); @@ -84,87 +85,24 @@ public class JobQueueManagerAPIImpl implements JobQueueManagerAPI { private final Map retryStrategies; private final RetryStrategy defaultRetryStrategy; - // The number of threads to use for job processing. - static final int DEFAULT_THREAD_POOL_SIZE = Config.getIntProperty( - "JOB_QUEUE_THREAD_POOL_SIZE", 10 - ); - - // The number of failures that will cause the circuit to open - static final int DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD = Config.getIntProperty( - "DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD", 5 - ); - - // The time in milliseconds after which to attempt to close the circuit - static final int DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT = Config.getIntProperty( - "DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT", 60000 - ); - - // The initial delay between retries in milliseconds - static final int DEFAULT_RETRY_STRATEGY_INITIAL_DELAY = Config.getIntProperty( - "DEFAULT_RETRY_STRATEGY_INITIAL_DELAY", 1000 - ); - - // The maximum delay between retries in milliseconds - static final int DEFAULT_RETRY_STRATEGY_MAX_DELAY = Config.getIntProperty( - "DEFAULT_RETRY_STRATEGY_MAX_DELAY", 60000 - ); - - // The factor by which the delay increases with each retry - static final float DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR = Config.getFloatProperty( - "DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR", 2.0f - ); - - // The maximum number of retry attempts allowed - static final int DEFAULT_RETRY_STRATEGY_MAX_RETRIES = Config.getIntProperty( - "DEFAULT_RETRY_STRATEGY_MAX_RETRIES", 5 - ); - - /** - * Constructs a new JobQueueManagerAPIImpl with the default job queue implementation and the default - * number of threads. - */ - public JobQueueManagerAPIImpl() { - // We don't have yet a JobQueue implementation, an implementation will be developed in a - // later task. - this(null, DEFAULT_THREAD_POOL_SIZE); - } - /** * Constructs a new JobQueueManagerAPIImpl. * - * @param jobQueue The JobQueue implementation to use. - * @param threadPoolSize The number of threads to use for job processing. + * @param jobQueue The JobQueue implementation to use. + * @param jobQueueConfig The JobQueueConfig implementation to use. + * @param circuitBreaker The CircuitBreaker implementation to use. + * @param defaultRetryStrategy The default retry strategy to use. */ - @VisibleForTesting - public JobQueueManagerAPIImpl(JobQueue jobQueue, int threadPoolSize) { - this(jobQueue, threadPoolSize, - new CircuitBreaker( - DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD, - DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT - ) - ); - } + @Inject + public JobQueueManagerAPIImpl(JobQueue jobQueue, JobQueueConfig jobQueueConfig, + CircuitBreaker circuitBreaker, RetryStrategy defaultRetryStrategy) { - /** - * Constructs a new JobQueueManagerAPIImpl. - * - * @param jobQueue The JobQueue implementation to use. - * @param threadPoolSize The number of threads to use for job processing. - * @param circuitBreaker The CircuitBreaker implementation to use. - */ - @VisibleForTesting - public JobQueueManagerAPIImpl(JobQueue jobQueue, int threadPoolSize, CircuitBreaker circuitBreaker) { this.jobQueue = jobQueue; - this.threadPoolSize = threadPoolSize; + this.threadPoolSize = jobQueueConfig.getThreadPoolSize(); this.processors = new ConcurrentHashMap<>(); this.jobWatchers = new ConcurrentHashMap<>(); this.retryStrategies = new ConcurrentHashMap<>(); - this.defaultRetryStrategy = new ExponentialBackoffRetryStrategy( - DEFAULT_RETRY_STRATEGY_INITIAL_DELAY, - DEFAULT_RETRY_STRATEGY_MAX_DELAY, - DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR, - DEFAULT_RETRY_STRATEGY_MAX_RETRIES - ); + this.defaultRetryStrategy = defaultRetryStrategy; this.circuitBreaker = circuitBreaker; } @@ -321,7 +259,19 @@ public void setRetryStrategy(final String queueName, final RetryStrategy retrySt @Override @VisibleForTesting public CircuitBreaker getCircuitBreaker() { - return circuitBreaker; + return this.circuitBreaker; + } + + @Override + @VisibleForTesting + public int getThreadPoolSize() { + return this.threadPoolSize; + } + + @Override + @VisibleForTesting + public RetryStrategy getDefaultRetryStrategy() { + return this.defaultRetryStrategy; } /** diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/CircuitBreaker.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/CircuitBreaker.java index a667f8cb462..ee6b0d3147b 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/error/CircuitBreaker.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/CircuitBreaker.java @@ -1,25 +1,47 @@ package com.dotcms.jobs.business.error; +import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; +import com.google.common.annotations.VisibleForTesting; +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; /** * Implements the Circuit Breaker pattern to prevent repeated failures in a system. It helps to * avoid cascading failures by temporarily disabling operations that are likely to fail. */ +@ApplicationScoped public class CircuitBreaker { - private final int failureThreshold; - private final long resetTimeout; + // The number of failures that will cause the circuit to open + static final int DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD = Config.getIntProperty( + "DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD", 5 + ); + + // The time in milliseconds after which to attempt to close the circuit + static final int DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT = Config.getIntProperty( + "DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT", 60000 + ); + + private int failureThreshold; + private long resetTimeout; private volatile int failureCount; private volatile long lastFailureTime; private volatile boolean isOpen; + @Inject + public CircuitBreaker() { + // Default constructor for CDI + } + /** * Constructs a new CircuitBreaker. * * @param failureThreshold the number of failures that will cause the circuit to open * @param resetTimeout the time in milliseconds after which to attempt to close the circuit */ + @VisibleForTesting public CircuitBreaker(final int failureThreshold, final long resetTimeout) { if (failureThreshold <= 0) { throw new IllegalArgumentException("Failure threshold must be greater than zero"); @@ -31,6 +53,17 @@ public CircuitBreaker(final int failureThreshold, final long resetTimeout) { this.resetTimeout = resetTimeout; } + /** + * Initializes the circuit breaker with default values. + */ + @PostConstruct + public void init() { + this.failureThreshold = DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD; + this.resetTimeout = DEFAULT_CIRCUIT_BREAKER_RESET_TIMEOUT; + Logger.info(this, "CircuitBreaker initialized with " + + "threshold: " + failureThreshold + ", resetTimeout: " + resetTimeout); + } + /** * Determines whether a request should be allowed to proceed. * diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java new file mode 100644 index 00000000000..1ad0da1f515 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java @@ -0,0 +1,49 @@ +package com.dotcms.jobs.business.error; + +import com.dotmarketing.util.Config; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Produces; + +/** + * This class is responsible for producing the default RetryStrategy used in the application. It is + * application-scoped, meaning a single instance is created for the entire application. + */ +@ApplicationScoped +public class RetryStrategyProducer { + + // The initial delay between retries in milliseconds + static final int DEFAULT_RETRY_STRATEGY_INITIAL_DELAY = Config.getIntProperty( + "DEFAULT_RETRY_STRATEGY_INITIAL_DELAY", 1000 + ); + + // The maximum delay between retries in milliseconds + static final int DEFAULT_RETRY_STRATEGY_MAX_DELAY = Config.getIntProperty( + "DEFAULT_RETRY_STRATEGY_MAX_DELAY", 60000 + ); + + // The factor by which the delay increases with each retry + static final float DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR = Config.getFloatProperty( + "DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR", 2.0f + ); + + // The maximum number of retry attempts allowed + static final int DEFAULT_RETRY_STRATEGY_MAX_RETRIES = Config.getIntProperty( + "DEFAULT_RETRY_STRATEGY_MAX_RETRIES", 5 + ); + + /** + * Produces a RetryStrategy instance. This method is called by the CDI container to create a + * RetryStrategy instance when it is needed for dependency injection. + * + * @return An ExponentialBackoffRetryStrategy instance configured with the default values. + */ + @Produces + public RetryStrategy produceDefaultRetryStrategy() { + return new ExponentialBackoffRetryStrategy( + DEFAULT_RETRY_STRATEGY_INITIAL_DELAY, + DEFAULT_RETRY_STRATEGY_MAX_DELAY, + DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR, + DEFAULT_RETRY_STRATEGY_MAX_RETRIES + ); + } +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java new file mode 100644 index 00000000000..210653cadc5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java @@ -0,0 +1,36 @@ +package com.dotcms.jobs.business.queue; + +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Produces; + +/** + * This class is responsible for producing the JobQueue implementation used in the application. It + * is application-scoped, meaning a single instance is created for the entire application. + */ +@ApplicationScoped +public class JobQueueProducer { + + /** + * Produces a JobQueue instance. This method is called by the CDI container to create a JobQueue + * instance when it is needed for dependency injection. + * + * @return A JobQueue instance + */ + @Produces + @ApplicationScoped + public JobQueue produceJobQueue() { + + // Potential future implementation: + // String queueType = System.getProperty("job.queue.type", "postgres"); + // if ("postgres".equals(queueType)) { + // return new PostgresJobQueue(); + // } else if ("redis".equals(queueType)) { + // return new RedisJobQueue(); + // } + // throw new IllegalStateException("Unknown job queue type: " + queueType); + + //return new PostgresJobQueue(); + return null; + } + +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorHelper.java index 3120b8a2cb2..9e411b0b642 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorHelper.java @@ -1,16 +1,12 @@ package com.dotcms.rest.api.v1.system.monitor; -import static com.dotcms.content.elasticsearch.business.ESIndexAPI.INDEX_OPERATIONS_TIMEOUT_IN_MS; - -import com.dotcms.content.elasticsearch.business.IndiciesInfo; -import com.dotcms.content.elasticsearch.util.RestHighLevelClientProvider; -import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.content.elasticsearch.business.ClusterStats; +import com.dotcms.exception.ExceptionUtil; import com.dotcms.util.HttpRequestDataUtil; import com.dotcms.util.network.IPUtils; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.common.db.DotConnect; -import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.util.Config; import com.dotmarketing.util.ConfigUtils; import com.dotmarketing.util.Logger; @@ -21,72 +17,98 @@ import io.vavr.Tuple; import io.vavr.Tuple2; import io.vavr.control.Try; + +import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.OutputStream; import java.nio.file.Files; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicReference; -import javax.servlet.http.HttpServletRequest; -import org.elasticsearch.action.search.SearchRequest; -import org.elasticsearch.client.RequestOptions; -import org.elasticsearch.common.unit.TimeValue; -import org.elasticsearch.index.query.QueryBuilders; -import org.elasticsearch.search.builder.SearchSourceBuilder; - +/** + * This class provides several utility methods aimed to check the status of the different subsystems + * of dotCMS, namely: + *
    + *
  • Database server connectivity.
  • + *
  • Elasticsearch server connectivity.
  • + *
  • Caching framework or server connectivity.
  • + *
  • File System access.
  • + *
  • Assets folder write/delete operations.
  • + *
+ * + * @author Brent Griffin + * @since Jul 18th, 2018 + */ class MonitorHelper { + final boolean accessGranted ; + final boolean useExtendedFormat; private static final String[] DEFAULT_IP_ACL_VALUE = new String[]{"127.0.0.1/32", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}; - - + private static final String IPV6_LOCALHOST = "0:0:0:0:0:0:0:1"; private static final String SYSTEM_STATUS_API_IP_ACL = "SYSTEM_STATUS_API_IP_ACL"; - private static final long SYSTEM_STATUS_CACHE_RESPONSE_SECONDS = Config.getLongProperty( "SYSTEM_STATUS_CACHE_RESPONSE_SECONDS", 10); private static final String[] ACLS_IPS = Config.getStringArrayProperty(SYSTEM_STATUS_API_IP_ACL, DEFAULT_IP_ACL_VALUE); - static final AtomicReference> cachedStats = new AtomicReference<>(); - boolean accessGranted = false; - boolean useExtendedFormat = false; + MonitorHelper(final HttpServletRequest request, final boolean heavyCheck) { + this.useExtendedFormat = heavyCheck; + this.accessGranted = isAccessGranted(request); + } - MonitorHelper(final HttpServletRequest request) { + /** + * Determines if the IP address of the request is allowed to access this monitor service. We use + * an ACL list to determine if the user/service accessing the monitor has permission to do so. + * ACL IPs can be defined via the {@code SYSTEM_STATUS_API_IP_ACL} property. + * + * @param request The current instance of the {@link HttpServletRequest}. + * + * @return If the IP address of the request is allowed to access this monitor service, returns + * {@code true}. + */ + boolean isAccessGranted(final HttpServletRequest request){ try { - this.useExtendedFormat = request.getParameter("extended") != null; - - // set this.accessGranted + if(IPV6_LOCALHOST.equals(request.getRemoteAddr()) || ACLS_IPS == null || ACLS_IPS.length == 0){ + return true; + } final String clientIP = HttpRequestDataUtil.getIpAddress(request).toString().split(StringPool.SLASH)[1]; - if (ACLS_IPS == null || ACLS_IPS.length == 0) { - this.accessGranted = true; - } else { - for (String aclIP : ACLS_IPS) { - if (IPUtils.isIpInCIDR(clientIP, aclIP)) { - this.accessGranted = true; - break; - } + + for (final String aclIP : ACLS_IPS) { + if (IPUtils.isIpInCIDR(clientIP, aclIP)) { + return true; } } - - - } catch (Exception e) { - Logger.warnAndDebug(this.getClass(), e.getMessage(), e); - throw new DotRuntimeException(e); + } catch (final Exception e) { + Logger.warnEveryAndDebug(this.getClass(), e, 60000); } + return false; } - boolean startedUp() { + /** + * Determines if dotCMS has started up by checking if the {@code dotcms.started.up} system + * property has been set. + * + * @return If dotCMS has started up, returns {@code true}. + */ + boolean isStartedUp() { return System.getProperty(WebKeys.DOTCMS_STARTED_UP)!=null; } - - + /** + * Retrieves the current status of the different subsystems of dotCMS. This method caches the + * response for a period of time defined by the {@code SYSTEM_STATUS_CACHE_RESPONSE_SECONDS} + * property. + * + * @return An instance of {@link MonitorStats} containing the status of the different + * subsystems. + */ MonitorStats getMonitorStats() { if (cachedStats.get() != null && cachedStats.get()._1 > System.currentTimeMillis()) { return cachedStats.get()._2; @@ -94,31 +116,26 @@ MonitorStats getMonitorStats() { return getMonitorStatsNoCache(); } - + /** + * Retrieves the current status of the different subsystems of dotCMS. If cached monitor stats + * are available, return them instead. + * + * @return An instance of {@link MonitorStats} containing the status of the different + * subsystems. + */ synchronized MonitorStats getMonitorStatsNoCache() { // double check if (cachedStats.get() != null && cachedStats.get()._1 > System.currentTimeMillis()) { return cachedStats.get()._2; } - - - - final MonitorStats monitorStats = new MonitorStats(); - - final IndiciesInfo indiciesInfo = Try.of(()->APILocator.getIndiciesAPI().loadIndicies()).getOrElseThrow(DotRuntimeException::new); - - monitorStats.subSystemStats.isDBHealthy = isDBHealthy(); - monitorStats.subSystemStats.isLiveIndexHealthy = isIndexHealthy(indiciesInfo.getLive()); - monitorStats.subSystemStats.isWorkingIndexHealthy = isIndexHealthy(indiciesInfo.getWorking()); - monitorStats.subSystemStats.isCacheHealthy = isCacheHealthy(); - monitorStats.subSystemStats.isLocalFileSystemHealthy = isLocalFileSystemHealthy(); - monitorStats.subSystemStats.isAssetFileSystemHealthy = isAssetFileSystemHealthy(); - - - monitorStats.serverId = getServerID(); - monitorStats.clusterId = getClusterID(); - - + final MonitorStats monitorStats = new MonitorStats + .Builder() + .cacheHealthy(isCacheHealthy()) + .assetFSHealthy(isAssetFileSystemHealthy()) + .localFSHealthy(isLocalFileSystemHealthy()) + .dBHealthy(isDBHealthy()) + .esHealthy(canConnectToES()) + .build(); // cache a healthy response if (monitorStats.isDotCMSHealthy()) { cachedStats.set( Tuple.of(System.currentTimeMillis() + (SYSTEM_STATUS_CACHE_RESPONSE_SECONDS * 1000), @@ -127,84 +144,74 @@ synchronized MonitorStats getMonitorStatsNoCache() { return monitorStats; } - + /** + * Determines if the database server is healthy by executing a simple query. + * + * @return If the database server is healthy, returns {@code true}. + */ boolean isDBHealthy() { - - return Try.of(()-> - new DotConnect().setSQL("SELECT count(*) as count FROM (SELECT 1 FROM dot_cluster LIMIT 1) AS t") + new DotConnect().setSQL("SELECT 1 as count") .loadInt("count")) .onFailure(e->Logger.warnAndDebug(MonitorHelper.class, "unable to connect to db:" + e.getMessage(),e)) .getOrElse(0) > 0; - - } - - boolean isIndexHealthy(final String index) { - - SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); - searchSourceBuilder.query(QueryBuilders.matchAllQuery()); - searchSourceBuilder.size(0); - searchSourceBuilder.timeout(TimeValue - .timeValueMillis(INDEX_OPERATIONS_TIMEOUT_IN_MS)); - searchSourceBuilder.fetchSource(new String[]{"inode"}, null); - SearchRequest searchRequest = new SearchRequest(); - searchRequest.source(searchSourceBuilder); - searchRequest.indices(index); - - long totalHits = Try.of(()-> - RestHighLevelClientProvider - .getInstance() - .getClient() - .search(searchRequest,RequestOptions.DEFAULT) - .getHits() - .getTotalHits() - .value) - .onFailure(e->Logger.warnAndDebug(MonitorHelper.class, "unable to connect to index:" + e.getMessage(),e)) - .getOrElse(0L); - - return totalHits > 0; - + /** + * Determines if dotCMS can connect to Elasticsearch by checking the ES Server cluster + * statistics. If they're available, it means dotCMS can connect to ES. + * + * @return If dotCMS can connect to Elasticsearch, returns {@code true}. + */ + boolean canConnectToES() { + try { + final ClusterStats stats = APILocator.getESIndexAPI().getClusterStats(); + return stats != null && stats.getClusterName() != null; + } catch (final Exception e) { + Logger.warnAndDebug(this.getClass(), + "Unable to connect to ES: " + ExceptionUtil.getErrorMessage(e), e); + return false; + } } - + /** + * Determines if the cache is healthy by checking if the SYSTEM_HOST identifier is available. + * + * @return If the cache is healthy, returns {@code true}. + */ boolean isCacheHealthy() { try { APILocator.getIdentifierAPI().find(Host.SYSTEM_HOST); return UtilMethods.isSet(APILocator.getIdentifierAPI().find(Host.SYSTEM_HOST).getId()); - } - catch (Exception e){ + } catch (final Exception e){ Logger.warnAndDebug(this.getClass(), "unable to find SYSTEM_HOST: " + e.getMessage(), e); return false; } - } + /** + * Determines if the local file system is healthy by writing a file to the Dynamic Content Path + * directory. + * + * @return If the local file system is healthy, returns {@code true}. + */ boolean isLocalFileSystemHealthy() { - return new FileSystemTest(ConfigUtils.getDynamicContentPath()).call(); - } + /** + * Determines if the asset file system is healthy by writing a file to the Asset Path + * directory. + * + * @return If the asset file system is healthy, returns {@code true}. + */ boolean isAssetFileSystemHealthy() { - return new FileSystemTest(ConfigUtils.getAssetPath()).call(); - - } - - - private String getServerID() { - return APILocator.getServerAPI().readServerId(); - - } - - private String getClusterID() { - return ClusterFactory.getClusterId(); - - } + /** + * This class is used to test the health of the file system by writing a file to a given path. + */ static final class FileSystemTest implements Callable { final String initialPath; @@ -229,10 +236,11 @@ public Boolean call() { return file.delete(); } } catch (Exception e) { - Logger.warnAndDebug(this.getClass(), e.getMessage(), e); + Logger.warnAndDebug(this.getClass(), "Unable to write a file to: " + initialPath + " : " +e.getMessage(), e); return false; } return false; } } + } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorResource.java index 112b327cc61..5b16f246b5a 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorResource.java @@ -2,7 +2,8 @@ import com.dotcms.business.CloseDBIfOpened; import com.dotcms.rest.annotation.NoCache; -import java.util.Map; +import org.glassfish.jersey.server.JSONP; + import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.GET; @@ -11,16 +12,20 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import org.glassfish.jersey.server.JSONP; - +import java.util.Map; +/** + * This REST Endpoint provides a set of probes to check the status of the different subsystems used + * by dotCMS. This tool is crucial for Engineering Teams to check that dotCMS is running properly. + * + * @author Brent Griffin + * @since Jul 18th, 2018 + */ @Path("/v1/{a:system-status|probes}") public class MonitorResource { - - private static final int SERVICE_UNAVAILABLE = HttpServletResponse.SC_SERVICE_UNAVAILABLE; - private static final int FORBIDDEN = HttpServletResponse.SC_FORBIDDEN; - + private static final int SERVICE_UNAVAILABLE = HttpServletResponse.SC_SERVICE_UNAVAILABLE; + private static final int FORBIDDEN = HttpServletResponse.SC_FORBIDDEN; /** * This /startup and /ready probe is heavy - it is intended to report on when dotCMS first comes up @@ -44,46 +49,33 @@ public class MonitorResource { @Path("/") @Produces(MediaType.APPLICATION_JSON) @CloseDBIfOpened - public Response statusCheck(final @Context HttpServletRequest request) { - final MonitorHelper helper = new MonitorHelper(request); + public Response heavyCheck(final @Context HttpServletRequest request) { + final MonitorHelper helper = new MonitorHelper(request , true); if(!helper.accessGranted) { return Response.status(FORBIDDEN).entity(Map.of()).build(); } - if(!helper.startedUp()) { + if(!helper.isStartedUp()) { return Response.status(SERVICE_UNAVAILABLE).build(); } if(!helper.getMonitorStats().isDotCMSHealthy()) { return Response.status(SERVICE_UNAVAILABLE).build(); } - if(helper.useExtendedFormat) { - return Response.ok(helper.getMonitorStats().toMap()).build(); - } - return Response.ok().build(); - + return Response.ok(helper.getMonitorStats().toMap()).build(); } - - - @NoCache @GET @JSONP - @Path("/{a:startup|ready}") + @Path("/{a:|startup|ready|heavy}") @Produces(MediaType.APPLICATION_JSON) @CloseDBIfOpened public Response ready(final @Context HttpServletRequest request) { - - return statusCheck(request); - + return heavyCheck(request); } - - - - /** * This /alive probe is lightweight - it checks if the server is up by requesting a common object from * the dotCMS cache layer twice in a row. By the time a request gets here it has @@ -93,35 +85,25 @@ public Response ready(final @Context HttpServletRequest request) { * @param request * @return */ - @GET - @Path("/alive") + @Path("/{a:alive|light}") @CloseDBIfOpened @Produces(MediaType.APPLICATION_JSON) - public Response aliveCheck(final @Context HttpServletRequest request) { - - - final MonitorHelper helper = new MonitorHelper(request); + public Response lightCheck(final @Context HttpServletRequest request) { + final MonitorHelper helper = new MonitorHelper(request, false); if(!helper.accessGranted) { return Response.status(FORBIDDEN).build(); } - if(!helper.startedUp()) { + if(!helper.isStartedUp()) { return Response.status(SERVICE_UNAVAILABLE).build(); } //try this twice as it is an imperfect test - if(helper.isCacheHealthy() && helper.isCacheHealthy()) { + if(helper.isCacheHealthy() ) { return Response.ok().build(); } - return Response.status(SERVICE_UNAVAILABLE).build(); - } - - - - - } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorStats.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorStats.java index 9fb0a1c0874..a7fb4e7c25f 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorStats.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/monitor/MonitorStats.java @@ -1,52 +1,129 @@ package com.dotcms.rest.api.v1.system.monitor; -import com.liferay.util.StringPool; import java.util.Map; +/** + * This class is used to report on the status of the various subsystems used by dotCMS. + * + * @author Brent Griffin + * @since Jul 18th, 2018 + */ +public class MonitorStats { + + final boolean assetFSHealthy; + final boolean cacheHealthy; + final boolean dBHealthy; + final boolean esHealthy; + final boolean localFSHealthy; + + public MonitorStats(boolean assetFSHealthy, + boolean cacheHealthy, + boolean dBHealthy, + boolean esHealthy, + boolean localFSHealthy) { + this.assetFSHealthy = assetFSHealthy; + this.cacheHealthy = cacheHealthy; + this.dBHealthy = dBHealthy; + this.esHealthy = esHealthy; + this.localFSHealthy = localFSHealthy; + } -class MonitorStats { - - final MonitorSubSystemStats subSystemStats = new MonitorSubSystemStats(); - String clusterId = StringPool.BLANK; - String serverId = StringPool.BLANK; - + /** + * This method checks if the dotCMS instance is healthy. It does this by checking if the backend + * and frontend are healthy. + * + * @return If the dotCMS instance is healthy, returns {@code true}. + */ boolean isDotCMSHealthy() { return isBackendHealthy() && isFrontendHealthy(); } + /** + * This method checks if the backend is healthy. It does this by checking if the database, + * elasticsearch, cache, local file system, and asset file system are healthy. + * + * @return If the backend is healthy, returns {@code true}. + */ boolean isBackendHealthy() { - return subSystemStats.isDBHealthy && subSystemStats.isLiveIndexHealthy && subSystemStats.isWorkingIndexHealthy - && - subSystemStats.isCacheHealthy && subSystemStats.isLocalFileSystemHealthy - && subSystemStats.isAssetFileSystemHealthy; + return this.dBHealthy && this.esHealthy && this.cacheHealthy && this.localFSHealthy + && this.assetFSHealthy; } + /** + * This method checks if the frontend is healthy. It does this by checking if the database, + * elasticsearch, cache, local file system, and asset file system are healthy. + * + * @return If the frontend is healthy, returns {@code true}. + */ boolean isFrontendHealthy() { - return subSystemStats.isDBHealthy && subSystemStats.isLiveIndexHealthy && subSystemStats.isCacheHealthy && - subSystemStats.isLocalFileSystemHealthy && subSystemStats.isAssetFileSystemHealthy; + return this.dBHealthy && this.esHealthy && this.cacheHealthy && + this.localFSHealthy && this.assetFSHealthy; } - + /** + * This method converts the monitor stats to a map. + * + * @return A map containing the monitor stats. + */ Map toMap() { - final Map subsystems = Map.of( - "dbSelectHealthy", subSystemStats.isDBHealthy, - "indexLiveHealthy", subSystemStats.isLiveIndexHealthy, - "indexWorkingHealthy", subSystemStats.isWorkingIndexHealthy, - "cacheHealthy", subSystemStats.isCacheHealthy, - "localFSHealthy", subSystemStats.isLocalFileSystemHealthy, - "assetFSHealthy", subSystemStats.isAssetFileSystemHealthy); + "dbSelectHealthy", this.dBHealthy, + "esHealthy", this.esHealthy, + "cacheHealthy", this.cacheHealthy, + "localFSHealthy", this.localFSHealthy, + "assetFSHealthy", this.assetFSHealthy); return Map.of( - "serverID", this.serverId, - "clusterID", this.clusterId, "dotCMSHealthy", this.isDotCMSHealthy(), "frontendHealthy", this.isFrontendHealthy(), "backendHealthy", this.isBackendHealthy(), "subsystems", subsystems); + } - - + /** + * This class is used to build an instance of {@link MonitorStats}. + */ + public static final class Builder { + + private boolean assetFSHealthy; + private boolean cacheHealthy; + private boolean dBHealthy; + private boolean esHealthy; + private boolean localFSHealthy; + + public Builder assetFSHealthy(boolean assetFSHealthy) { + this.assetFSHealthy = assetFSHealthy; + return this; + } + + public Builder cacheHealthy(boolean cacheHealthy) { + this.cacheHealthy = cacheHealthy; + return this; + } + + public Builder dBHealthy(boolean dBHealthy) { + this.dBHealthy = dBHealthy; + return this; + } + + public Builder localFSHealthy(boolean localFSHealthy) { + this.localFSHealthy = localFSHealthy; + return this; + } + + public Builder esHealthy(boolean esHealthy) { + this.esHealthy = esHealthy; + return this; + } + + public MonitorStats build() { + return new MonitorStats( + assetFSHealthy, + cacheHealthy, + dBHealthy, + esHealthy, + localFSHealthy); + } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityDefaultWorkflowActionsView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityDefaultWorkflowActionsView.java new file mode 100644 index 00000000000..fb6e681c0b7 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityDefaultWorkflowActionsView.java @@ -0,0 +1,11 @@ +package com.dotcms.rest.api.v1.workflow; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.List; + +public class ResponseEntityDefaultWorkflowActionsView extends ResponseEntityView> { + public ResponseEntityDefaultWorkflowActionsView(List entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntitySystemActionWorkflowActionMappings.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntitySystemActionWorkflowActionMappings.java new file mode 100644 index 00000000000..cfb9a6d6106 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntitySystemActionWorkflowActionMappings.java @@ -0,0 +1,11 @@ +package com.dotcms.rest.api.v1.workflow; + +import com.dotcms.rest.ResponseEntityView; +import com.dotmarketing.portlets.workflows.model.SystemActionWorkflowActionMapping; +import java.util.List; + +public class ResponseEntitySystemActionWorkflowActionMappings extends ResponseEntityView> { + public ResponseEntitySystemActionWorkflowActionMappings(List entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityWorkflowSchemeView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityWorkflowSchemeView.java new file mode 100644 index 00000000000..3dfa0e3f508 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityWorkflowSchemeView.java @@ -0,0 +1,11 @@ +package com.dotcms.rest.api.v1.workflow; + +import com.dotcms.rest.ResponseEntityView; +import com.dotmarketing.portlets.workflows.model.WorkflowScheme; + + +public class ResponseEntityWorkflowSchemeView extends ResponseEntityView { + public ResponseEntityWorkflowSchemeView(WorkflowScheme entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityWorkflowStepView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityWorkflowStepView.java new file mode 100644 index 00000000000..9fe1fc1523b --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/ResponseEntityWorkflowStepView.java @@ -0,0 +1,10 @@ +package com.dotcms.rest.api.v1.workflow; + +import com.dotcms.rest.ResponseEntityView; +import com.dotmarketing.portlets.workflows.model.WorkflowStep; + +public class ResponseEntityWorkflowStepView extends ResponseEntityView { + public ResponseEntityWorkflowStepView(WorkflowStep entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java index 3848e75359b..47af0f6af53 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/workflow/WorkflowResource.java @@ -1,5 +1,6 @@ package com.dotcms.rest.api.v1.workflow; +import com.dotcms.api.system.user.UserException; import com.dotcms.api.web.HttpServletRequestThreadLocal; import com.dotcms.business.WrapInTransaction; import com.dotcms.concurrent.DotConcurrentFactory; @@ -23,6 +24,7 @@ import com.dotcms.rest.api.v1.authentication.ResponseUtil; import com.dotcms.rest.exception.BadRequestException; import com.dotcms.rest.exception.ForbiddenException; +import com.dotcms.rest.exception.NotFoundException; import com.dotcms.util.CollectionsUtils; import com.dotcms.util.ConversionUtils; import com.dotcms.util.DotPreconditions; @@ -84,12 +86,14 @@ import com.dotmarketing.util.json.JSONObject; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableSet; +import com.liferay.portal.UserIdException; import com.liferay.portal.language.LanguageException; import com.liferay.portal.language.LanguageUtil; import com.liferay.portal.model.User; import com.liferay.util.HttpHeaders; import com.liferay.util.StringPool; import com.rainerhahnekamp.sneakythrow.Sneaky; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -279,9 +283,9 @@ public WorkflowResource() { schema = @Schema(implementation = ResponseEntityWorkflowSchemesView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Content type not found"), + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -289,11 +293,12 @@ public final Response findSchemes(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @QueryParam("contentTypeId") @Parameter( description = "Optional filter parameter that takes a content type identifier and returns " + - "all workflow schemes associated with that type.\n\n" + + "all [workflow schemes](https://www.dotcms.com/docs/latest/managing-workflows#Schemes) " + + "associated with that type.\n\n" + "Leave blank to return all workflow schemes.\n\n" + "Example value: `c541abb1-69b3-4bc5-8430-5e09e5239cc8` " + "(Default page content type)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String contentTypeId, @DefaultValue("true") @QueryParam("showArchived") @Parameter( description = "If `true`, includes archived schemes in response.", @@ -330,7 +335,8 @@ public final Response findSchemes(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionlets", summary = "Find all workflow actionlets", - description = "Returns a list of all workflow actionlets, a.k.a. workflow sub-actions. " + + description = "Returns a list of all workflow actionlets, a.k.a. [workflow sub-actions]" + + "(https://www.dotcms.com/docs/latest/workflow-sub-actions). " + "The returned list is complete and does not use pagination.", tags = {"Workflow"}, responses = { @@ -339,7 +345,7 @@ public final Response findSchemes(@Context final HttpServletRequest request, schema = @Schema(implementation = ResponseEntityWorkflowActionletsView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } @@ -374,8 +380,8 @@ public final Response findActionlets(@Context final HttpServletRequest request) @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionletsByActionId", summary = "Find workflow actionlets by workflow action", - description = "Returns a list of the workflow actionlets, a.k.a. workflow sub-actions, associated with " + - "a specified workflow action.", + description = "Returns a list of the workflow actionlets, a.k.a. [workflow sub-actions](https://www.dotcms." + + "com/docs/latest/workflow-sub-actions), associated with a specified workflow action.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Workflow actionlets returned successfully", @@ -383,9 +389,9 @@ public final Response findActionlets(@Context final HttpServletRequest request) schema = @Schema(implementation = ResponseEntityWorkflowActionClassesView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow action not found."), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -396,7 +402,7 @@ public final Response findActionletsByAction(@Context final HttpServletRequest r "actionlets.\n\n" + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + "(Default system workflow \"Publish\" action)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String actionId) { final InitDataObject initDataObject = this.webResource.init @@ -428,8 +434,8 @@ public final Response findActionletsByAction(@Context final HttpServletRequest r @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowSchemesByContentTypeId", summary = "Find workflow schemes by content type id", - description = "Fetches workflow schemes associated with a content type by its identifier. Returns an entity " + - "containing two properties:\n\n" + + description = "Fetches [workflow schemes](https://www.dotcms.com/docs/latest/managing-workflows#Schemes) " + + " associated with a content type by its identifier. Returns an entity containing two properties:\n\n" + "| Property | Description |\n" + "|----------|-------------|\n" + "| `contentTypeSchemes` | A list of schemes associated with the specified content type. |\n" + @@ -441,9 +447,9 @@ public final Response findActionletsByAction(@Context final HttpServletRequest r schema = @Schema(implementation = SchemesAndSchemesContentTypeView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Content type ID not found."), + @ApiResponse(responseCode = "404", description = "Content type ID not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -454,7 +460,7 @@ public final Response findAllSchemesAndSchemesByContentType( required = true, description = "Identifier of content type to examine for workflow schemes.\n\n" + "Example value: `c541abb1-69b3-4bc5-8430-5e09e5239cc8` (Default page content type)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String contentTypeId) { final InitDataObject initDataObject = this.webResource.init @@ -493,7 +499,8 @@ public final Response findAllSchemesAndSchemesByContentType( @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowStepsBySchemeId", summary = "Find steps by workflow scheme ID", - description = "Returns a list of steps associated with a workflow scheme.", + description = "Returns a list of [steps](https://www.dotcms.com/docs/latest/managing-workflows#Steps) " + + "associated with a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Scheme(s) returned successfully", @@ -501,9 +508,9 @@ public final Response findAllSchemesAndSchemesByContentType( schema = @Schema(implementation = ResponseEntityWorkflowStepsView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow scheme not found."), + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -514,7 +521,7 @@ public final Response findStepsByScheme(@Context final HttpServletRequest reques description = "Identifier of workflow scheme.\n\n" + "Example value: `d61a59e1-a49c-46f2-a929-db2b4bfa88b2` " + "(Default system workflow)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String schemeId) { this.webResource.init @@ -553,7 +560,8 @@ public final Response findStepsByScheme(@Context final HttpServletRequest reques @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionsByContentletInode", summary = "Finds workflow actions by content inode", - description = "Returns a list of workflow actions associated with a contentlet specified by inode.", + description = "Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "associated with a [contentlet](https://www.dotcms.com/docs/latest/content#Contentlets) specified by inode.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Scheme(s) returned successfully", @@ -561,9 +569,9 @@ public final Response findStepsByScheme(@Context final HttpServletRequest reques schema = @Schema(implementation = ResponseEntityWorkflowActionsView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Contentlet not found."), + @ApiResponse(responseCode = "404", description = "Contentlet not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -572,7 +580,7 @@ public final Response findAvailableActions(@Context final HttpServletRequest req @PathParam("inode") @Parameter( required = true, description = "Inode of contentlet to examine for workflow actions.\n\n", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String inode, @QueryParam("renderMode") @Parameter( description = "*Optional.* Case-insensitive parameter indicating " + @@ -693,8 +701,9 @@ private static List createActionInputViews (final WorkflowActio @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) @Operation(operationId = "postBulkActions", summary = "Finds available bulk workflow actions for content", - description = "Returns a list of bulk actions available for contentlets specified either by identifiers " + - "or by query, as specified in the POST body.", + description = "Returns a list of bulk actions available for " + + "[contentlets](https://www.dotcms.com/docs/latest/content#Contentlets) either by identifiers " + + "or by query, as specified in the body.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Zero or more bulk actions returned successfully", @@ -703,7 +712,7 @@ private static List createActionInputViews (final WorkflowActio ) ), @ApiResponse(responseCode = "400", description = "Bad request"), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } @@ -711,7 +720,7 @@ private static List createActionInputViews (final WorkflowActio public final Response getBulkActions(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @RequestBody( - description = "POST body consists of a JSON object with either of the following properties:\n\n" + + description = "Body consists of a JSON object with either of the following properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + "| `contentletIds` | List of Strings | A list of individual contentlet identifiers. |\n" + @@ -761,8 +770,8 @@ public final Response getBulkActions(@Context final HttpServletRequest request, @Consumes({MediaType.APPLICATION_JSON}) @Operation(operationId = "putBulkActionsFire", summary = "Perform workflow actions on bulk content", description = "This operation allows you to specify a multiple content items (either by query or a list of " + - "identifiers), a workflow action to perform on them, and additional parameters as needed by " + - "the selected action.", + "identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "to perform on them, and additional parameters as needed by the selected action.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Success", @@ -771,7 +780,7 @@ public final Response getBulkActions(@Context final HttpServletRequest request, ) ), @ApiResponse(responseCode = "400", description = "Bad request"), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } @@ -779,7 +788,7 @@ public final Response getBulkActions(@Context final HttpServletRequest request, public final void fireBulkActions(@Context final HttpServletRequest request, @Suspended final AsyncResponse asyncResponse, @RequestBody( - description = "PUT body consists of a JSON object with the following possible properties:\n\n" + + description = "Body consists of a JSON object with the following possible properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + "| `contentletIds` | List of Strings | A list of individual contentlet identifiers. |\n" + @@ -788,11 +797,9 @@ public final void fireBulkActions(@Context final HttpServletRequest request, "| `workflowActionId` | String | The identifier of the workflow action to be performed on the " + "selected content. |\n" + "| `additionalParams` | Object | Further parameters and properties are conveyed here, depending " + - "on the particulars of the selected action. For example, an " + - "action using the Send Form Email actionlet would require " + - "parameters like `fromEmail` or `emailSubject`.

For a " + + "on the particulars of the selected action.

For a " + "complete list of possible parameters, refer to the various " + - "keys listed in the `/actionlets` GET method. |\n\n" + + "keys listed in `GET /workflow/actionlets`. |\n\n" + "If both `contentletIds` and `query` properties are present, the operation will use the query and " + "disregard the identifier list.", required = true, @@ -828,8 +835,8 @@ public final void fireBulkActions(@Context final HttpServletRequest request, @Consumes({MediaType.APPLICATION_JSON}) @Operation(operationId = "postBulkActionsFire", summary = "Perform workflow actions on bulk content", description = "This operation allows you to specify a multiple content items (either by query or a list of " + - "identifiers), a workflow action to perform on them, and additional parameters as needed by " + - "the selected action.", + "identifiers), a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "to perform on them, and additional parameters as needed by the selected action.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Success", @@ -838,14 +845,14 @@ public final void fireBulkActions(@Context final HttpServletRequest request, ) ), @ApiResponse(responseCode = "400", description = "Bad request"), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) public EventOutput fireBulkActions(@Context final HttpServletRequest request, @RequestBody( - description = "POST body consists of a JSON object with the following possible properties:\n\n" + + description = "Body consists of a JSON object with the following possible properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + "| `contentletIds` | List of Strings | A list of individual contentlet identifiers. |\n" + @@ -854,11 +861,9 @@ public EventOutput fireBulkActions(@Context final HttpServletRequest request, "| `workflowActionId` | String | The identifier of the workflow action to be performed on the " + "selected content. |\n" + "| `additionalParams` | Object | Further parameters and properties are conveyed here, depending " + - "on the particulars of the selected action. For example, an " + - "action using the Send Form Email actionlet would require " + - "parameters like `fromEmail` or `emailSubject`.

For a " + + "on the particulars of the selected action.

For a " + "complete list of possible parameters, refer to the various " + - "keys listed in the `/actionlets` GET method. |\n\n" + + "keys listed in `GET /workflow/actionlets`. |\n\n" + "If both `contentletIds` and `query` properties are present, the operation will perform the " + "selected action on all contentlets indicated in both. Note that this will lead to the workflow " + "action being performed on the same contentlet twice, if it appears in both.", @@ -929,7 +934,7 @@ public EventOutput fireBulkActions(@Context final HttpServletRequest request, @IncludePermissions @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionByActionId", summary = "Find action by ID", - description = "Returns a workflow action object.", + description = "Returns a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) object.", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Action returned successfully", @@ -937,9 +942,9 @@ public EventOutput fireBulkActions(@Context final HttpServletRequest request, schema = @Schema(implementation = ResponseEntityWorkflowActionView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow action not found."), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -950,7 +955,7 @@ public final Response findAction(@Context final HttpServletRequest request, description = "Identifier of the workflow action to return.\n\n" + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + "(Default system workflow \"Publish\" action)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String actionId) { final InitDataObject initDataObject = this.webResource.init @@ -982,7 +987,8 @@ public final Response findAction(@Context final HttpServletRequest request, @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowConditionByActionId", summary = "Find condition by action ID", description = "Returns a string representing the \"condition\" on the selected action.\n\n" + - "More specifically: if the workflow action has anything in its Custom Code field, " + + "More specifically: if the workflow action has anything in its [Custom Code]" + + "(https://www.dotcms.com/docs/latest/custom-workflow-actions) field, " + "the result is evaluated as Velocity, and the output is returned.", tags = {"Workflow"}, responses = { @@ -991,9 +997,9 @@ public final Response findAction(@Context final HttpServletRequest request, schema = @Schema(implementation = ResponseEntityStringView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow action not found."), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1005,7 +1011,7 @@ public final Response evaluateActionCondition( description = "Identifier of a workflow action to check for condition.\n\n" + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + "(Default system workflow \"Publish\" action)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String actionId) { final InitDataObject initDataObject = this.webResource.init @@ -1036,7 +1042,8 @@ public final Response evaluateActionCondition( @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionByStepActionId", summary = "Find a workflow action within a step", - description = "Returns a workflow action if it exists within a specific step.", + description = "Returns a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "if it exists within a specific [step](https://www.dotcms.com/docs/latest/managing-workflows#Steps).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Action returned successfully from step", @@ -1044,9 +1051,9 @@ public final Response evaluateActionCondition( schema = @Schema(implementation = ResponseEntityWorkflowActionView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow action not found within specified step."), + @ApiResponse(responseCode = "404", description = "Workflow action not found within specified step"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1057,14 +1064,14 @@ public final Response findActionByStep(@Context final HttpServletRequest request description = "Identifier of a workflow step.\n\n" + "Example value: `ee24a4cb-2d15-4c98-b1bd-6327126451f3` " + "(Default system workflow \"Draft\" step)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String stepId, @PathParam("actionId") @Parameter( required = true, description = "Identifier of a workflow action.\n\n" + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + "(Default system workflow \"Publish\" action)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String actionId) { final InitDataObject initDataObject = this.webResource.init @@ -1095,7 +1102,9 @@ public final Response findActionByStep(@Context final HttpServletRequest request @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionsByStepId", summary = "Find all actions in a workflow step", - description = "Returns a list of workflow actions associated with a specified workflow step.", + description = "Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing" + + "-workflows#Actions) associated with a specified [workflow step](https://www.dotcms.com/" + + "docs/latest/managing-workflows#Steps).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Actions returned successfully from step", @@ -1103,9 +1112,9 @@ public final Response findActionByStep(@Context final HttpServletRequest request schema = @Schema(implementation = ResponseEntityWorkflowActionsView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow step not found."), + @ApiResponse(responseCode = "404", description = "Workflow step not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1116,7 +1125,7 @@ public final Response findActionsByStep(@Context final HttpServletRequest reques description = "Identifier of a workflow step.\n\n" + "Example value: `ee24a4cb-2d15-4c98-b1bd-6327126451f3` " + "(Default system workflow \"Draft\" step)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String stepId) { final InitDataObject initDataObject = this.webResource.init @@ -1146,7 +1155,9 @@ public final Response findActionsByStep(@Context final HttpServletRequest reques @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Operation(operationId = "getWorkflowActionsBySchemeId", summary = "Find all actions in a workflow scheme", - description = "Returns a list of workflow actions associated with a specified workflow scheme.", + description = "Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing-" + + "workflows#Actions) associated with a specified [workflow scheme](https://www.dotcms.com/" + + "docs/latest/managing-workflows#Schemes).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Actions returned successfully from workflow scheme", @@ -1154,9 +1165,9 @@ public final Response findActionsByStep(@Context final HttpServletRequest reques schema = @Schema(implementation = ResponseEntityWorkflowActionsView.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow scheme not found."), + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1167,7 +1178,7 @@ public final Response findActionsByScheme(@Context final HttpServletRequest requ description = "Identifier of workflow scheme.\n\n" + "Example value: `d61a59e1-a49c-46f2-a929-db2b4bfa88b2` " + "(Default system workflow)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String schemeId) { final InitDataObject initDataObject = this.webResource.init @@ -1198,8 +1209,9 @@ public final Response findActionsByScheme(@Context final HttpServletRequest requ @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) @Operation(operationId = "postFindActionsBySchemesAndSystemAction", summary = "Finds workflow actions by schemes and system action", - description = "Returns a list of workflow actions associated with workflow schemes, further filtered " + - "by [default system workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions).", + description = "Returns a list of [workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "associated with [workflow schemes](https://www.dotcms.com/docs/latest/managing-workflows#Schemes), further " + + "filtered by [default system actions](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Workflow action(s) returned successfully", @@ -1208,7 +1220,7 @@ public final Response findActionsByScheme(@Context final HttpServletRequest requ ) ), @ApiResponse(responseCode = "400", description = "Bad request"), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } @@ -1217,10 +1229,18 @@ public final Response findActionsBySchemesAndSystemAction(@Context final HttpSer @Context final HttpServletResponse response, @PathParam("systemAction") @Parameter( required = true, - description = "Default workflow action." + schema = @Schema( + type = "string", + allowableValues = { + "NEW", "EDIT", "PUBLISH", + "UNPUBLISH", "ARCHIVE", "UNARCHIVE", + "DELETE", "DESTROY" + } + ), + description = "Default system action." ) final WorkflowAPI.SystemAction systemAction, @RequestBody( - description = "POST body consists of a JSON object containing " + + description = "Body consists of a JSON object containing " + "a single property called `schemes`, which contains a " + "list of workflow scheme identifier strings.", required = true, @@ -1268,19 +1288,20 @@ public final Response findActionsBySchemesAndSystemAction(@Context final HttpSer @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation(operationId = "getSystemActionMappingsBySchemeId", summary = "Find default actions mapped to a workflow scheme", - description = "Returns a list of [default system workflow actions](https://www.dotcms.com/docs/latest/managing-" + - "workflows#DefaultActions) associated with a specified workflow scheme.", + @Operation(operationId = "getSystemActionMappingsBySchemeId", summary = "Find default system actions mapped to a workflow scheme", + description = "Returns a list of [default system actions](https://www.dotcms.com/docs/latest/managing-" + + "workflows#DefaultActions) associated with a specified [workflow scheme](https://www.dotcms.com" + + "/docs/latest/managing-workflows#Schemes).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Actions returned successfully from workflow scheme", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMapping.class) + schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMappings.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow scheme not found."), + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1291,7 +1312,7 @@ public final Response findSystemActionsByScheme(@Context final HttpServletReques description = "Identifier of workflow scheme.\n\n" + "Example value: `d61a59e1-a49c-46f2-a929-db2b4bfa88b2` " + "(Default system workflow)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String schemeId) { final InitDataObject initDataObject = this.webResource.init @@ -1321,19 +1342,20 @@ public final Response findSystemActionsByScheme(@Context final HttpServletReques @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation(operationId = "getSystemActionMappingsByContentType", summary = "Find default actions mapped to a workflow scheme", - description = "Returns a list of [default system workflow actions](https://www.dotcms.com/docs/latest/managing-" + - "workflows#DefaultActions) associated with a specified workflow scheme.", + @Operation(operationId = "getSystemActionMappingsByContentType", summary = "Find default system actions mapped to a content type", + description = "Returns a list of [default system actions](https://www.dotcms.com/docs/latest/managing-" + + "workflows#DefaultActions) associated with a specified [content type](https://www.dotcms.com" + + "/docs/latest/content-types).", tags = {"Workflow"}, responses = { - @ApiResponse(responseCode = "200", description = "Action(s) returned successfully from workflow scheme", + @ApiResponse(responseCode = "200", description = "Action(s) returned successfully from content type", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMapping.class) + schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMappings.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Content Type not found."), + @ApiResponse(responseCode = "404", description = "Content Type not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1341,7 +1363,8 @@ public final Response findSystemActionsByContentType(@Context final HttpServletR @Context final HttpServletResponse response, @PathParam("contentTypeVarOrId") @Parameter( required = true, - description = "The ID or Velocity variable of the content type to update.\n\n" + + description = "The ID or Velocity variable of the content type to inspect" + + "for default system action bindings.\n\n" + "Example value: `htmlpageasset` (Default page content type)", schema = @Schema(type = "string") ) final String contentTypeVarOrId) { @@ -1375,20 +1398,20 @@ public final Response findSystemActionsByContentType(@Context final HttpServletR @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation(operationId = "getSystemActionsByActionId", summary = "Find default actions by workflow action id", - description = "Returns a list of " + - "[default system workflow actions](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) " + - "associated with a specified workflow action.", + @Operation(operationId = "getSystemActionsByActionId", summary = "Find default system actions by workflow action id", + description = "Returns a list of [default system actions]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) associated with a " + + "specified [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions).", tags = {"Workflow"}, responses = { @ApiResponse(responseCode = "200", description = "Action(s) returned successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMapping.class) + schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMappings.class) ) ), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Workflow action not found."), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) @@ -1399,7 +1422,7 @@ public final Response getSystemActionsReferredByWorkflowAction(@Context final Ht description = "Identifier of the workflow action to return.\n\n" + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + "(Default system workflow \"Publish\" action)", - schema = @Schema(type = "string", format = "uuid") + schema = @Schema(type = "string") ) final String workflowActionId) { final InitDataObject initDataObject = this.webResource.init @@ -1435,12 +1458,15 @@ public final Response getSystemActionsReferredByWorkflowAction(@Context final Ht @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) - @Operation(operationId = "putSystemActions", summary = "Save a default workflow action mapping", - description = "This operation allows you to save a [default system workflow action]" + + @Operation(operationId = "putSaveSystemActions", summary = "Save a default system action mapping", + description = "This operation allows you to save a [default system action]" + "(https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) mapping. This requires:\n\n" + - "1. Selecting the default workflow action to be mapped;\n" + - "2. Specifying a workflow action to be performed when that default action is called;\n" + - "3. Associating this mapping with either a workflow scheme or a content type.\n\n" + + "1. Selecting a default system action to be mapped;\n" + + "2. Specifying a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "to be performed when that system action is called;\n" + + "3. Associating this mapping with either a [workflow scheme](https://www.dotcms.com/docs/latest" + + "/managing-workflows#Schemes) or a [content type](https://www.dotcms.com/docs/latest" + + "/content-types).\n\n" + "See the request body below for further details.", tags = {"Workflow"}, responses = { @@ -1450,7 +1476,7 @@ public final Response getSystemActionsReferredByWorkflowAction(@Context final Ht ) ), @ApiResponse(responseCode = "400", description = "Bad request"), - @ApiResponse(responseCode = "401", description = "Insufficient Permissions"), + @ApiResponse(responseCode = "401", description = "Invalid User"), @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "500", description = "Internal Server Error") } @@ -1458,21 +1484,23 @@ public final Response getSystemActionsReferredByWorkflowAction(@Context final Ht public final Response saveSystemAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @RequestBody( - description = "PUT body consists of a JSON object with the following properties:\n\n" + + description = "Body consists of a JSON object with the following properties:\n\n" + "| Property | Type | Description |\n" + "|-|-|-|\n" + - "| `systemAction` | String | A default workflow action, such as `NEW` or `PUBLISH`. |\n" + + "| `systemAction` | String | A default system action, such as `NEW` or `PUBLISH`. |\n" + "| `actionId` | String | The identifier of an action that will be performed " + - "by the specified default action. |\n" + + "by the specified system action. |\n" + "| `schemeId` | String | The identifier of a workflow scheme to be associated " + - "with the default action. |\n" + + "with the system action. |\n" + "| `contentTypeVariable` | String | The variable of a content type to be " + - "associated with the default action.|\n\n" + + "associated with the system action. Note that the content type must already " + + "have the schema assigned as one of its valid workflows in order to bind " + + "a system action from said schema. |\n\n" + "If both the `schemeId` and `contentTypeVariable` are specified, the scheme " + "identifier takes precedence, and the content type variable is disregarded.", required = true, content = @Content(schema = @Schema(implementation = WorkflowSystemActionForm.class)) - )final WorkflowSystemActionForm workflowSystemActionForm) { + ) final WorkflowSystemActionForm workflowSystemActionForm) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1512,9 +1540,38 @@ public final Response saveSystemAction(@Context final HttpServletRequest request @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "deleteSystemActionByActionId", summary = "Delete default system action binding by action id", + description = "Deletes a [default system action]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) binding.\n\n" + + "Returns the deleted system action object.\n\n" + + "This method is minimally destructive, as it neither deletes a [workflow action](https://www.dotcms" + + ".com/docs/latest/managing-workflows#Actions), nor removes any system action category. Instead, it " + + "dissolves the association between the two, which can be re-established any time.\n\n" + + "To find a suitable identifier, you can use `GET /system/actions/{workflowActionId}` " + + "and find it in the immediate `identifier` property of any of the objects returned in the entity.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "System action binding deleted successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntitySystemActionWorkflowActionMapping.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response deletesSystemAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("identifier") final String identifier) { + @PathParam("identifier") @Parameter( + required = true, + description = "Identifier of the system action mapping to delete.\n\n" + + "Example value: `59995336-187e-442a-b398-04b9f137eabd` " + + "(Demo starter binding that maps `DELETE` system action to " + + "the \"Destroy\" workflow action for the Blog content type)", + schema = @Schema(type = "string") + ) final String identifier) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1547,9 +1604,64 @@ public final Response deletesSystemAction(@Context final HttpServletRequest requ @NoCache @Consumes({MediaType.APPLICATION_JSON}) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "postActionsByWorkflowActionForm", summary = "Creates/saves a workflow action", + description = "Creates or updates a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "from the properties specified in the payload. Returns the created workflow action.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow action created successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowActionView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response saveAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - final WorkflowActionForm workflowActionForm) { + @RequestBody( + description = "Body consists of a JSON object containing " + + "a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "form. This includes the following properties:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `actionId` | String | The identifier of the workflow action to be updated. " + + "If left blank, a new workflow action will be created. |\n" + + "| `schemeId` | String | The [workflow scheme](https://www.dotcms.com/docs/latest" + + "/managing-workflows#Schemes) under which the action will be created. |\n" + + "| `stepId` | String | The [workflow step](https://www.dotcms.com/docs/latest" + + "/managing-workflows#Steps) with which to associate the action. |\n" + + "| `actionName` | String | The name of the workflow action. Multiple actions of the " + + "same name can coexist with different identifiers. |\n" + + "| `whoCanUse` | List of Strings | A list of identifiers representing [users]" + + "(https://www.dotcms.com/docs/latest/user-management), " + + "[role keys](https://www.dotcms.com/docs/latest/adding-roles), " + + "or [other user categories](https://www.dotcms.com" + + "/docs/latest/managing-workflows#ActionWho) allowed " + + "to use this action. This list can be empty. |\n" + + "| `actionIcon` | String | The icon to associate with the action. Example: `workflowIcon`. |\n" + + "| `actionCommentable` | Boolean | Whether this action supports comments. |\n" + + /* "| `requiresCheckout` | Boolean | |\n" + // This is a deprecated, unnecessary, and broadly unused property. */ + "| `showOn` | List of Strings | List defining under which of the eight valid [workflow states]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#ActionShow) the " + + "action is visible. States must be specified uppercase, such as `NEW` or " + + "`LOCKED`. There is no single state for ALL; each state must be listed. |\n" + + "| `actionNextStep` | String | The identifier of the step to enter after performing the action. |\n" + + "| `actionNextAssign` | String | A user identifier or role key (such as `CMS Anonymous`) to serve as the " + + " default entry in the assignment dropdown. |\n" + + "| `actionCondition` | String | [Custom Velocity code](https://www.dotcms.com/docs/latest/managing-workflows#" + + "ActionAssign) to be executed along with the action. |\n" + + "| `actionAssignable` | Boolean | Whether this action can be assigned. |\n" + + "| `actionRoleHierarchyForAssign` | Boolean | If true, non-administrators cannot assign tasks to administrators. |\n" + + "| `metadata` | Object | Additional metadata to include in the action definition. |\n\n", + required = true, + content = @Content( + schema = @Schema(implementation = WorkflowActionForm.class) + ) + ) final WorkflowActionForm workflowActionForm) throws NotFoundException { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1584,10 +1696,69 @@ public final Response saveAction(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "putSaveActionsByWorkflowActionForm", summary = "Update an existing workflow action", + description = "Updates a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "based on the payload properties.\n\nReturns updated workflow action.\n\n", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Updated workflow action successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowActionView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response updateAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("actionId") final String actionId, - final WorkflowActionForm workflowActionForm) { + @PathParam("actionId") @Parameter( + required = true, + description = "Identifier of workflow action to update.\n\n" + + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + + "(Default system workflow \"Publish\" action)", + schema = @Schema(type = "string") + ) final String actionId, + @RequestBody( + description = "Body consists of a JSON object containing " + + "a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "form. This includes the following properties:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `actionId` | String | The identifier of the workflow action to be updated. " + + "If left blank, a new workflow action will be created. |\n" + + "| `schemeId` | String | The [workflow scheme](https://www.dotcms.com/docs/latest" + + "/managing-workflows#Schemes) under which the action will be created. |\n" + + "| `stepId` | String | The [workflow step](https://www.dotcms.com/docs/latest" + + "/managing-workflows#Steps) with which to associate the action. |\n" + + "| `actionName` | String | The name of the workflow action. Multiple actions of the " + + "same name can coexist with different identifiers. |\n" + + "| `whoCanUse` | List of Strings | A list of identifiers representing [users]" + + "(https://www.dotcms.com/docs/latest/user-management), " + + "[role keys](https://www.dotcms.com/docs/latest/adding-roles), " + + "or [other user categories](https://www.dotcms.com" + + " /docs/latest/managing-workflows#ActionWho) " + + "allowed to use this action. This list can be empty. |\n" + + "| `actionIcon` | String | The icon to associate with the action. Example: `workflowIcon`. |\n" + + "| `actionCommentable` | Boolean | Whether this action supports comments. |\n" + + /* "| `requiresCheckout` | Boolean | |\n" + // This is a deprecated, unnecessary, and broadly unused property. */ + "| `showOn` | List of Strings | List defining under which of the eight valid [workflow states]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#ActionShow) the " + + "action is visible. States must be specified uppercase, such as `NEW` or " + + "`LOCKED`. There is no single state for ALL; each state must be listed. |\n" + + "| `actionNextStep` | String | The identifier of the step to enter after performing the action. |\n" + + "| `actionNextAssign` | String | A user identifier or role key (such as `CMS Anonymous`) to serve as the " + + " default entry in the assignment dropdown. |\n" + + "| `actionCondition` | String | [Custom Velocity code](https://www.dotcms.com/docs/latest/managing-workflows#" + + "ActionAssign) to be executed along with the action. |\n" + + "| `actionAssignable` | Boolean | Whether this action can be assigned. |\n" + + "| `actionRoleHierarchyForAssign` | Boolean | If true, non-administrators cannot assign tasks to administrators. |\n" + + "| `metadata` | Object | Additional metadata to include in the action definition. |\n\n", + required = true, + content = @Content(schema = @Schema(implementation = WorkflowActionForm.class)) + ) final WorkflowActionForm workflowActionForm) { final InitDataObject initDataObject = this.webResource.init(null, request, response, true, null); try { @@ -1616,10 +1787,79 @@ public final Response updateAction(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "postActionToStepById", summary = "Adds a workflow action to a workflow step", + description = "Assigns a single [workflow action](https://www.dotcms.com/docs/latest" + + "/managing-workflows#Actions) to a [workflow step](https://www.dotcms.com/docs" + + "/latest/managing-workflows#Steps). Returns \"Ok\" on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow action added to step successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class), + examples = @ExampleObject( + value = "{\n" + + " \"errors\": [\n" + + " {\n" + + " \"errorCode\": \"string\",\n" + + " \"message\": \"string\",\n" + + " \"fieldName\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"entity\": \"Ok\",\n" + + " \"messages\": [\n" + + " {\n" + + " \"message\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"i18nMessagesMap\": {\n" + + " \"additionalProp1\": \"string\",\n" + + " \"additionalProp2\": \"string\",\n" + + " \"additionalProp3\": \"string\"\n" + + " },\n" + + " \"permissions\": [\n" + + " \"string\"\n" + + " ],\n" + + " \"pagination\": {\n" + + " \"currentPage\": 0,\n" + + " \"perPage\": 0,\n" + + " \"totalEntries\": 0\n" + + " }\n" + + "}" + ) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response saveActionToStep(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("stepId") final String stepId, - final WorkflowActionStepForm workflowActionStepForm) { + @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of a workflow step to receive a new action.\n\n" + + "Example value: `ee24a4cb-2d15-4c98-b1bd-6327126451f3` " + + "(Default system workflow \"Draft\" step)", + schema = @Schema(type = "string") + ) final String stepId, + @RequestBody( + description = "Body consists of a JSON object with a single property:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `actionId` | String | The identifier of the workflow action " + + "to assign to the step specified in the " + + "parameter. |\n\n", + required = true, + content = @Content(schema = @Schema(implementation = WorkflowActionStepForm.class), + examples = @ExampleObject( + value = "{\n" + + " \"actionId\": " + + "\"b9d89c80-3d88-4311-8365-187323c96436\"\n" + + "}" + ) + ) + ) final WorkflowActionStepForm workflowActionStepForm) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1651,9 +1891,79 @@ public final Response saveActionToStep(@Context final HttpServletRequest request @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "postAddActionletToActionById", summary = "Adds an actionlet to a workflow action", + description = "Adds an actionlet — also known as a [workflow sub-action]" + + "(https://www.dotcms.com/docs/latest/workflow-sub-actions) — to a [workflow action]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#Actions).\n\n" + + "Returns \"Ok\" on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow actionlet assigned successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class), + examples = @ExampleObject( + value = "{\n" + + " \"errors\": [\n" + + " {\n" + + " \"errorCode\": \"string\",\n" + + " \"message\": \"string\",\n" + + " \"fieldName\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"entity\": \"Ok\",\n" + + " \"messages\": [\n" + + " {\n" + + " \"message\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"i18nMessagesMap\": {\n" + + " \"additionalProp1\": \"string\",\n" + + " \"additionalProp2\": \"string\",\n" + + " \"additionalProp3\": \"string\"\n" + + " },\n" + + " \"permissions\": [\n" + + " \"string\"\n" + + " ],\n" + + " \"pagination\": {\n" + + " \"currentPage\": 0,\n" + + " \"perPage\": 0,\n" + + " \"totalEntries\": 0\n" + + " }\n" + + "}" + ) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response saveActionletToAction(@Context final HttpServletRequest request, - @PathParam("actionId") final String actionId, - final WorkflowActionletActionForm workflowActionletActionForm) { + @PathParam("actionId") @Parameter( + required = true, + description = "Identifier of workflow action to receive actionlet.\n\n" + + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + + "(Default system workflow \"Publish\" action)", + schema = @Schema(type = "string") + ) final String actionId, + @RequestBody( + description = "Body consists of a JSON object containing " + + "a workflow action form. This includes the following properties:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `actionletClass` | String | The class of the actionlet to be assigned.

Example: " + + "`com.dotcms.rendering.js.JsScriptActionlet` |\n" + + "| `order` | Integer | The position of the actionlet within the action's sequence. |\n" + + "| `parameters` | Object | Further parameters and properties are conveyed here, depending " + + "on the particulars of the selected actionlet.

For a complete list of " + + "possible parameters, refer to the various keys listed in " + + "`GET /workflow/actionlets`. |\n\n", + required = true, + content = @Content( + schema = @Schema(implementation = WorkflowActionletActionForm.class) + ) + ) final WorkflowActionletActionForm workflowActionletActionForm) { final InitDataObject initDataObject = this.webResource.init (null, true, request, true, null); @@ -1688,9 +1998,30 @@ public final Response saveActionletToAction(@Context final HttpServletRequest re @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "deleteWorkflowStepById", summary = "Delete a workflow step", + description = "Deletes a [step](https://www.dotcms.com/docs/latest/managing-workflows#Steps) from a " + + "[workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).\n\n" + + "Returns the deleted workflow step object.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow step deleted successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowStepView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final void deleteStep(@Context final HttpServletRequest request, @Suspended final AsyncResponse asyncResponse, - @PathParam("stepId") final String stepId) { + @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of a workflow step to delete.", + schema = @Schema(type = "string") + ) final String stepId) { final InitDataObject initDataObject = this.webResource.init (null, request, new EmptyHttpResponse(), true, null); @@ -1717,10 +2048,36 @@ public final void deleteStep(@Context final HttpServletRequest request, @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "deleteWorkflowActionFromStepByActionId", summary = "Remove a workflow action from a step", + description = "Deletes an [action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) from a " + + "single [workflow step](https://www.dotcms.com/docs/latest/managing-workflows#Steps).\n\n" + + "Returns \"Ok\" on success.\n\n" + + "If the action exists on other steps, removing it from one step will not delete the action outright.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow action removed from step successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response deleteAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("actionId") final String actionId, - @PathParam("stepId") final String stepId) { + @PathParam("actionId") @Parameter( + required = true, + description = "Identifier of the workflow action to remove.", + schema = @Schema(type = "string") + ) final String actionId, + @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of the step containing the action.", + schema = @Schema(type = "string") + ) final String stepId) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1750,9 +2107,30 @@ public final Response deleteAction(@Context final HttpServletRequest request, @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "deleteWorkflowActionByActionId", summary = "Delete a workflow action", + description = "Deletes a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions) " + + "from all [steps](https://www.dotcms.com/docs/latest/managing-workflows#Steps) in which it appears.\n\n" + + "Returns \"Ok\" on success.\n\n", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow action deleted successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response deleteAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("actionId") final String actionId) { + @PathParam("actionId") @Parameter( + required = true, + description = "Identifier of the workflow action to delete.", + schema = @Schema(type = "string") + ) final String actionId) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1782,8 +2160,31 @@ public final Response deleteAction(@Context final HttpServletRequest request, @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "deleteWorkflowActionletFromAction", summary = "Remove an actionlet from a workflow action", + description = "Removes an [actionlet](https://www.dotcms.com/docs/latest/workflow-sub-actions), or sub-action, " + + "from a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions). This deletes " + + "only the actionlet's binding to the action utilizing it, and leaves the actionlet category intact.\n\n" + + "To find the identifier, you can call `GET /workflow/actions/{actionId}/actionlets`." + + "Returns \"Ok\" on success.\n\n", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow actionlet deleted from action successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response deleteActionlet(@Context final HttpServletRequest request, - @PathParam("actionletId") final String actionletId) { + @PathParam("actionletId") @Parameter( + required = true, + description = "Identifier of the actionlet to delete.", + schema = @Schema(type = "string") + ) final String actionletId) { final InitDataObject initDataObject = this.webResource.init (null, true, request, true, null); @@ -1826,10 +2227,37 @@ public final Response deleteActionlet(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "putReorderWorkflowStepsInScheme", summary = "Change the order of steps within a scheme", + description = "Updates a [workflow step](https://www.dotcms.com/docs/latest/managing-workflows#Steps)'s " + + "order within a [scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes) by " + + "assigning it a numeric order.\n\nReturns \"Ok\" on success.\n\n", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow step reordered successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response reorderStep(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("stepId") final String stepId, - @PathParam("order") final int order) { + @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of the step to reorder.\n\n" + + "Example: `ee24a4cb-2d15-4c98-b1bd-6327126451f3` (Default system workflow Draft step)", + schema = @Schema(type = "string") + ) final String stepId, + @PathParam("order") @Parameter( + required = true, + description = "Integer indicating the step's position in the order, with `0` as the first. " + + "All other steps numbers are adjusted accordingly, leaving no gaps.", + schema = @Schema(type = "integer") + ) final int order) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -1860,10 +2288,51 @@ public final Response reorderStep(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "putUpdateWorkflowStepById", summary = "Update an existing workflow step", + description = "Updates a [workflow step](https://www.dotcms.com/docs/latest/managing-workflows#Steps).\n\n" + + "Returns an object representing the updated step.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Updated workflow step successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowStepView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response updateStep(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @NotNull @PathParam("stepId") final String stepId, - final WorkflowStepUpdateForm stepForm) { + @NotNull @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of the step to update.\n\n" + + "Example: `ee24a4cb-2d15-4c98-b1bd-6327126451f3` (Default system workflow Draft step)", + schema = @Schema(type = "string") + ) final String stepId, + @RequestBody( + description = "Body consists of a JSON object containing " + + "a workflow step update form. This includes the following properties:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `stepOrder` | Integer | The position of the step within the [workflow scheme]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#Schemes), " + + "with `0` being the first. |\n" + + "| `stepName` | String | The name of the workflow step. |\n" + + "| `enableEscalation` | Boolean | Determines whether a step is capable of automatic escalation " + + "to the next step.\n\n(Read more about [schedule-enabled workflows]" + + "(https://www.dotcms.com/docs/latest/schedule-enabled-workflow).) |\n" + + "| `escalationAction` | String | The identifier of the workflow action to execute on automatic escalation. |\n" + + "| `escalationTime` | String | The time, in seconds, before the workflow automatically escalates. |\n" + + "| `stepResolved` | Boolean | If true, any content which enters this workflow step will be considered resolved.\n" + + "Content in a resolved step will not appear in the workflow queues of any users.\n |\n\n", + required = true, + content = @Content( + schema = @Schema(implementation = WorkflowStepUpdateForm.class) + ) + ) final WorkflowStepUpdateForm stepForm) { final InitDataObject initDataObject = this.webResource.init(null, request, response, true, null); Logger.debug(this, "updating step for scheme with stepId: " + stepId); try { @@ -1890,9 +2359,44 @@ public final Response updateStep(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "postAddWorkflowStep", summary = "Add a new workflow step", + description = "Creates a [workflow step](https://www.dotcms.com/docs/latest/managing-workflows#Steps).\n\n" + + "Returns an object representing the step.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Created workflow step successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowStepView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response addStep(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - final WorkflowStepAddForm newStepForm) { + @RequestBody( + description = "Body consists of a JSON object containing " + + "a workflow step update form. This includes the following properties:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `schemeId` | String | The identifier of the [workflow scheme](https://www.dotcms.com/docs" + + "/latest/managing-workflows#Schemes) to which the step will be added. |\n" + + "| `stepName` | String | The name of the workflow step. |\n" + + "| `enableEscalation` | Boolean | Determines whether a step is capable of automatic escalation " + + "to the next step.\n\n(Read more about [schedule-enabled workflows]" + + "(https://www.dotcms.com/docs/latest/schedule-enabled-workflow).) |\n" + + "| `escalationAction` | String | The identifier of the workflow action to execute on automatic escalation. |\n" + + "| `escalationTime` | String | The time, in seconds, before the workflow automatically escalates. |\n" + + "| `stepResolved` | Boolean | If true, any content which enters this workflow step will be considered resolved.\n" + + "Content in a resolved step will not appear in the workflow queues of any users.\n |\n\n", + required = true, + content = @Content( + schema = @Schema(implementation = WorkflowStepAddForm.class) + ) + ) final WorkflowStepAddForm newStepForm) { String schemeId = null; try { DotPreconditions.notNull(newStepForm,"Expected Request body was empty."); @@ -1921,9 +2425,30 @@ public final Response addStep(@Context final HttpServletRequest request, @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getFindWorkflowStepById", summary = "Retrieves a workflow step", + description = "Returns a [workflow step](https://www.dotcms.com/docs/latest/managing-workflows#Steps) by identifier.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Found workflow step successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowStepView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "405", description = "Method Not Allowed"), // if param string blank + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response findStepById(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @NotNull @PathParam("stepId") final String stepId) { + @NotNull @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of the step to retrieve.\n\n" + + "Example: `ee24a4cb-2d15-4c98-b1bd-6327126451f3` (Default system workflow Draft step)", + schema = @Schema(type = "string") + ) final String stepId) { this.webResource.init(null, request, response, true, null); Logger.debug(this, "finding step by id stepId: " + stepId); try { @@ -1937,6 +2462,88 @@ public final Response findStepById(@Context final HttpServletRequest request, } } + /** + * Wrapper function around fireActionByNameMultipart, allowing the `/actions/fire` method receiving + * multipart-form data also to be called from `/actions/firemultipart`. + * Swagger UI doesn't allow endpoint overloading, so this was created as an alias — both to + * surface the endpoint and preserve backwards compatibility. + * The wrapped function receives the @Hidden annotation, which explicitly omits it from the UI. + * All other Swagger-specific annotations have been moved off of the original and on to this one. + */ + @PUT + @Path("/actions/firemultipart") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(operationId = "putFireActionByNameMultipart", summary = "Fire action by name (multipart form) \uD83D\uDEA7", + description = "(**Construction notice:** Still awaiting request body documentation. Coming soon!)\n\n" + + "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + + "specified by name, on a target contentlet. Uses a multipart form to transmit its data.\n\n" + + "Returns a map of the resultant contentlet, with an additional " + + "`AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + + "services that handle automatically assigning workflow schemes to content with none.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Fired action successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) + public final Response fireActionByNameMultipartNewPath(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @QueryParam("inode") @Parameter( + description = "Inode of the target content.", + schema = @Schema(type = "string") + ) final String inode, + @QueryParam("identifier") @Parameter( + description = "Identifier of target content.", + schema = @Schema(type = "string") + ) final String identifier, + @QueryParam("indexPolicy") @Parameter( + description = "Determines how target content is indexed.\n\n" + + "| Value | Description |\n" + + "|-------|-------------|\n" + + "| `DEFER` | Content will be indexed asynchronously, outside of " + + "the current process. Valid content will finish the " + + "method in process and be returned before the content " + + "becomes visible in the index. This is the default " + + "index policy; it is resource-friendly and well-" + + "suited to batch processing. |\n" + + "| `WAIT_FOR` | The API call will not return from the content check " + + "process until the content has been indexed. Ensures content " + + "is promptly available for searching. |\n" + + "| `FORCE` | Forces Elasticsearch to index the content **immediately**.
" + + "**Caution:** Using this value may cause system performance issues; " + + "it is not recommended for general use, though may be useful " + + "for testing purposes. |\n\n", + schema = @Schema( + type = "string", + allowableValues = {"DEFER", "WAIT_FOR", "FORCE"}, + defaultValue = "" + ) + ) final String indexPolicy, + @DefaultValue("-1") @QueryParam("language") @Parameter( + description = "Language version of target content.", + schema = @Schema(type = "string") + ) final String language, + @RequestBody( + description = "Multipart form. More details to follow.", + required = true, + content = @Content( + schema = @Schema(implementation = FormDataMultiPart.class) + ) + ) final FormDataMultiPart multipart) { + return fireActionByNameMultipart(request, response, inode, identifier, indexPolicy, language, multipart); + } + /** * Fires a workflow action by name and multi part, if the contentlet exists could use inode or identifier and optional language. * @param request {@link HttpServletRequest} @@ -1953,13 +2560,7 @@ public final Response findStepById(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes(MediaType.MULTIPART_FORM_DATA) - @Operation(summary = "Fire action by name multipart", - responses = { - @ApiResponse( - responseCode = "200", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "404", description = "Action not found")}) + @Hidden public final Response fireActionByNameMultipart(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @QueryParam("inode") final String inode, @@ -2021,19 +2622,128 @@ public final Response fireActionByNameMultipart(@Context final HttpServletReques @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) - @Operation(summary = "Fire action by name", + @Operation(operationId = "putFireActionByName", summary = "Fire workflow action by name", + description = "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + + "specified by name, on a target contentlet.\n\nReturns a map of the resultant contentlet, " + + "with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + + "services that handle automatically assigning workflow schemes to content with none.", + tags = {"Workflow"}, responses = { - @ApiResponse( - responseCode = "200", + @ApiResponse(responseCode = "200", description = "Fired action successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "400", description = "Action not found")}) + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response fireActionByNameSinglePart(@Context final HttpServletRequest request, - @QueryParam("inode") final String inode, - @QueryParam("identifier") final String identifier, - @QueryParam("indexPolicy") final String indexPolicy, - @DefaultValue("-1") @QueryParam("language") final String language, - final FireActionByNameForm fireActionForm) { + @QueryParam("inode") @Parameter( + description = "Inode of the target content.", + schema = @Schema(type = "string") + ) final String inode, + @QueryParam("identifier") @Parameter( + description = "Identifier of target content.", + schema = @Schema(type = "string") + ) final String identifier, + @QueryParam("indexPolicy") @Parameter( + description = "Determines how target content is indexed.\n\n" + + "| Value | Description |\n" + + "|-------|-------------|\n" + + "| `DEFER` | Content will be indexed asynchronously, outside of " + + "the current process. Valid content will finish the " + + "method in process and be returned before the content " + + "becomes visible in the index. This is the default " + + "index policy; it is resource-friendly and well-" + + "suited to batch processing. |\n" + + "| `WAIT_FOR` | The API call will not return from the content check " + + "process until the content has been indexed. Ensures content " + + "is promptly available for searching. |\n" + + "| `FORCE` | Forces Elasticsearch to index the content **immediately**.
" + + "**Caution:** Using this value may cause system performance issues; " + + "it is not recommended for general use, though may be useful " + + "for testing purposes. |\n\n", + schema = @Schema( + type = "string", + allowableValues = {"DEFER", "WAIT_FOR", "FORCE"}, + defaultValue = "" + ) + ) final String indexPolicy, + @DefaultValue("-1") @QueryParam("language") @Parameter( + description = "Language version of target content.", + schema = @Schema(type = "string") + ) final String language, + @RequestBody( + description = "Body consists of a JSON object containing at minimum the " + + "`actionName` property, specifying a workflow action to fire.\n\n" + + "The full list of properties that may be used with this form " + + "is as follows:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `actionName` | String | The name of the workflow action to perform. |\n" + + "| `contentlet` | Object | An alternate way of specifying the target contentlet. " + + "If no identifier or inode is included via parameter, " + + "either one could instead be included in the body as a " + + "property of this object. |\n" + + "| `comments` | String | Comments that will appear in the [workflow tasks]" + + "(https://www.dotcms.com/docs/latest/workflow-tasks) " + + "tool with the execution of this workflow action. |\n" + + "| `individualPermissions` | Object | Allows setting granular permissions associated " + + "with the target. The object properties are the [system names " + + "of permissions](https://www.dotcms.com/docs/latest/user-permissions#Permissions), " + + "such as READ, PUBLISH, EDIT, etc. Their respective values " + + "are a list of user or role identifiers that should be granted " + + "the permission in question. Example: `\"READ\": " + + "[\"9ad24203-ae6a-4e5e-aa10-a8c38fd11f17\",\"MyRole\"]` |\n" + + "| `assign` | String | The identifier of a user or role to next receive the " + + "workflow task assignment. |\n" + + "| `pathToMove` | String | If the workflow action includes the Move actionlet, " + + "this property will specify the target path. This path " + + "must include a host, such as `//default/testfolder`, " + + "`//demo.dotcms.com/application`, etc. |\n" + + "| `query` | String | Not used in this method. |\n" + + "| `whereToSend` | String | For the [push publishing](push-publishing) actionlet; " + + "sets the push-publishing environment to receive the " + + "target content. Must be specified as an environment " + + "identifier. [Learn how to find environment IDs here.]" + + "(https://www.dotcms.com/docs/latest/push-publishing-endpoints#EnvironmentIds) |\n" + + "| `iWantTo` | String | For the push publishing actionlet; " + + "this can be set to one of three values:
  • `publish` for " + + "push publish;
  • `expire` for remove;
  • `publishexpire` " + + "for push remove.
These are further configurable with the " + + "properties below that specify publishing and expiration " + + "dates, times, etc. |\n" + + "| `publishDate` | String | For the push publishing actionlet; " + + "specifies a date to push the content. Format: `yyyy-MM-dd`. |\n" + + "| `publishTime` | String | For the push publishing actionlet; " + + "specifies a time to push the content. Format: `hh-mm`. |\n" + + "| `expireDate` | String | For the push publishing actionlet; " + + "specifies a date to remove the content. Format: `yyyy-MM-dd`. |\n" + + "| `expireTime` | String | For the push publishing actionlet; " + + "specifies a time to remove the content. Format: `hh-mm`. |\n" + + "| `neverExpire` | Boolean | For the push publishing actionlet; " + + "a value of `true` invalidates the expiration time/date. |\n" + + "| `filterKey` | String | For the push publishing actionlet; " + + "specifies a [push publishing filter](https://www.dotcms.com/docs/latest" + + "/push-publishing-filters) key, should the workflow action " + + "call for such. To retrieve a full list of push publishing " + + "filters and their keys, use `GET /v1/pushpublish/filters`. |\n" + + "| `timezoneId` | String | For the push publishing actionlet; " + + "specifies the time zone to which the indicated times belong. " + + "Uses the [tz database](https://www.iana.org/time-zones). " + + "For a list of values, see [the database directly]" + + "(https://data.iana.org/time-zones/tz-link.html) or refer to " + + "[the Wikipedia entry listing tz database time zones]" + + "(https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |\n\n", + required = true, + content = @Content( + schema = @Schema(implementation = FireActionByNameForm.class) + ) + ) final FireActionByNameForm fireActionForm) { final InitDataObject initDataObject = new WebResource.InitBuilder() .requestAndResponse(request, new MockHttpResponse()) @@ -2185,21 +2895,140 @@ private boolean needSave (final FireActionForm fireActionForm) { @NoCache @Consumes({MediaType.APPLICATION_JSON}) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation(summary = "Fire default action by name", + @Operation(operationId = "putFireDefaultSystemAction", summary = "Fire system action by name", + description = "Fire a [default system action](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) " + + "by name on a target contentlet.\n\nReturns a map of the resultant contentlet, " + + "with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + + "services that handle automatically assigning workflow schemes to content with none.", + tags = {"Workflow"}, responses = { - @ApiResponse( - responseCode = "200", + @ApiResponse(responseCode = "200", description = "Fired action successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "400", description = "Action not found")}) + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response fireActionDefaultSinglePart(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - @QueryParam("inode") final String inode, - @QueryParam("identifier") final String identifier, - @QueryParam("indexPolicy") final String indexPolicy, - @DefaultValue("-1") @QueryParam("language") final String language, - @PathParam("systemAction") final WorkflowAPI.SystemAction systemAction, - final FireActionForm fireActionForm) { + @Context final HttpServletResponse response, + @QueryParam("inode") @Parameter( + description = "Inode of the target content.", + schema = @Schema(type = "string") + ) final String inode, + @QueryParam("identifier") @Parameter( + description = "Identifier of target content.", + schema = @Schema(type = "string") + ) final String identifier, + @QueryParam("indexPolicy") @Parameter( + description = "Determines how target content is indexed.\n\n" + + "| Value | Description |\n" + + "|-------|-------------|\n" + + "| `DEFER` | Content will be indexed asynchronously, outside of " + + "the current process. Valid content will finish the " + + "method in process and be returned before the content " + + "becomes visible in the index. This is the default " + + "index policy; it is resource-friendly and well-" + + "suited to batch processing. |\n" + + "| `WAIT_FOR` | The API call will not return from the content check " + + "process until the content has been indexed. Ensures content " + + "is promptly available for searching. |\n" + + "| `FORCE` | Forces Elasticsearch to index the content **immediately**.
" + + "**Caution:** Using this value may cause system performance issues; " + + "it is not recommended for general use, though may be useful " + + "for testing purposes. |\n\n", + schema = @Schema( + type = "string", + allowableValues = {"DEFER", "WAIT_FOR", "FORCE"}, + defaultValue = "" + ) + ) final String indexPolicy, + @DefaultValue("-1") @QueryParam("language") @Parameter( + description = "Language version of target content.", + schema = @Schema(type = "string") + ) final String language, + @PathParam("systemAction") @Parameter( + required = true, + schema = @Schema( + type = "string", + allowableValues = { + "NEW", "EDIT", "PUBLISH", + "UNPUBLISH", "ARCHIVE", "UNARCHIVE", + "DELETE", "DESTROY" + } + ), + description = "Default system action." + ) final WorkflowAPI.SystemAction systemAction, + @RequestBody( + description = "Optional body consists of a JSON object containing a FireActionByNameForm " + + "object — a form that appears in similar functions, as well, but implemented with " + + "minor differences across methods. As such, some properties are unused.\n\n" + + "The full list of properties that may be used with this form is as follows:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `actionName` | String | Not used in this method. |\n" + + "| `contentlet` | Object | An alternate way of specifying the target contentlet. " + + "If no identifier or inode is included via parameter, " + + "either one could instead be included in the body as a " + + "property of this object. |\n" + + "| `comments` | String | Comments that will appear in the [workflow tasks]" + + "(https://www.dotcms.com/docs/latest/workflow-tasks) " + + "tool with the execution of this workflow action. |\n" + + "| `individualPermissions` | Object | Allows setting granular permissions associated " + + "with the target. The object properties are the [system names " + + "of permissions](https://www.dotcms.com/docs/latest/user-permissions#Permissions), " + + "such as READ, PUBLISH, EDIT, etc. Their respective values " + + "are a list of user or role identifiers that should be granted " + + "the permission in question. Example: `\"READ\": " + + "[\"9ad24203-ae6a-4e5e-aa10-a8c38fd11f17\",\"MyRole\"]` |\n" + + "| `assign` | String | The identifier of a user or role to next receive the " + + "workflow task assignment. |\n" + + "| `pathToMove` | String | If the workflow action includes the Move actionlet, " + + "this property will specify the target path. This path " + + "must include a host, such as `//default/testfolder`, " + + "`//demo.dotcms.com/application`, etc. |\n" + + "| `query` | String | Not used in this method. |\n" + + "| `whereToSend` | String | For the [push publishing](push-publishing) actionlet; " + + "sets the push-publishing environment to receive the " + + "target content. Must be specified as an environment " + + "identifier. [Learn how to find environment IDs here.]" + + "(https://www.dotcms.com/docs/latest/push-publishing-endpoints#EnvironmentIds) |\n" + + "| `iWantTo` | String | For the push publishing actionlet; " + + "this can be set to one of three values:
  • `publish` for " + + "push publish;
  • `expire` for remove;
  • `publishexpire` " + + "for push remove.
These are further configurable with the " + + "properties below that specify publishing and expiration " + + "dates, times, etc. |\n" + + "| `publishDate` | String | For the push publishing actionlet; " + + "specifies a date to push the content. Format: `yyyy-MM-dd`. |\n" + + "| `publishTime` | String | For the push publishing actionlet; " + + "specifies a time to push the content. Format: `hh-mm`. |\n" + + "| `expireDate` | String | For the push publishing actionlet; " + + "specifies a date to remove the content. Format: `yyyy-MM-dd`. |\n" + + "| `expireTime` | String | For the push publishing actionlet; " + + "specifies a time to remove the content. Format: `hh-mm`. |\n" + + "| `neverExpire` | Boolean | For the push publishing actionlet; " + + "a value of `true` invalidates the expiration time/date. |\n" + + "| `filterKey` | String | For the push publishing actionlet; " + + "specifies a [push publishing filter](https://www.dotcms.com/docs/latest" + + "/push-publishing-filters) key, should the workflow action " + + "call for such. To retrieve a full list of push publishing " + + "filters and their keys, use `GET /v1/pushpublish/filters`. |\n" + + "| `timezoneId` | String | For the push publishing actionlet; " + + "specifies the time zone to which the indicated times belong. " + + "Uses the [tz database](https://www.iana.org/time-zones). " + + "For a list of values, see [the database directly]" + + "(https://data.iana.org/time-zones/tz-link.html) or refer to " + + "[the Wikipedia entry listing tz database time zones]" + + "(https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |\n\n", + content = @Content( + schema = @Schema(implementation = FireActionByNameForm.class) + ) + ) final FireActionForm fireActionForm) { final InitDataObject initDataObject = new WebResource.InitBuilder() .requestAndResponse(request, response) @@ -2278,24 +3107,98 @@ public final Response fireActionDefaultSinglePart(@Context final HttpServletRequ * @param systemAction {@link com.dotmarketing.portlets.workflows.business.WorkflowAPI.SystemAction} system action to determine the default action * @return Response */ - @POST() + @POST @Path("/actions/default/fire/{systemAction}") @JSONP @NoCache //@Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes(MediaType.APPLICATION_JSON) @Produces("application/octet-stream") - @Operation(summary = "Fire default action by name on multiple contents", + @Operation(operationId = "postFireSystemActionByNameMulti", summary = "Fire system action by name over multiple contentlets \uD83D\uDEA7", + description = "(**Construction notice:** This endpoint currently cannot succeed on calls through the playground, " + + "though curl and other methods work fine.)\n\n" + + "Fire a [default system action](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) " + + "by name on multiple target contentlets.", + tags = {"Workflow"}, responses = { - @ApiResponse( - responseCode = "200", + @ApiResponse(responseCode = "200", description = "Fired action successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "400", description = "Action not found")}) + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "406", description = "Not acceptable"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response fireMultipleActionDefault(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("systemAction") final WorkflowAPI.SystemAction systemAction, - final FireMultipleActionForm fireActionForm) throws DotDataException, DotSecurityException { + @PathParam("systemAction") @Parameter( + required = true, + schema = @Schema( + type = "string", + allowableValues = { + "NEW", "EDIT", "PUBLISH", + "UNPUBLISH", "ARCHIVE", "UNARCHIVE", + "DELETE", "DESTROY" + } + ), + description = "Default system action." + ) final WorkflowAPI.SystemAction systemAction, + @RequestBody( + description = "Optional body consists of a JSON object containing various properties, " + + "some of which are specific to certain actionlets.\n\n" + + "The full list of properties that may be used with this form is as follows:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `contentlet` | List of Objects | Multiple contentlet objects to serve " + + "as the target of the selected default system action; requires, at minimum, " + + "an identifier in each. |\n" + + "| `comments` | String | Comments that will appear in the [workflow tasks]" + + "(https://www.dotcms.com/docs/latest/workflow-tasks) " + + "tool with the execution of this workflow action. |\n" + + "| `assign` | String | The identifier of a user or role to next receive the " + + "workflow task assignment. |\n" + + "| `whereToSend` | String | For the [push publishing](push-publishing) actionlet; " + + "sets the push-publishing environment to receive the " + + "target content. Must be specified as an environment " + + "identifier. [Learn how to find environment IDs here.]" + + "(https://www.dotcms.com/docs/latest/push-publishing-endpoints#EnvironmentIds) |\n" + + "| `iWantTo` | String | For the push publishing actionlet; " + + "this can be set to one of three values:
  • `publish` for " + + "push publish;
  • `expire` for remove;
  • `publishexpire` " + + "for push remove.
These are further configurable with the " + + "properties below that specify publishing and expiration " + + "dates, times, etc. |\n" + + "| `publishDate` | String | For the push publishing actionlet; " + + "specifies a date to push the content. Format: `yyyy-MM-dd`. |\n" + + "| `publishTime` | String | For the push publishing actionlet; " + + "specifies a time to push the content. Format: `hh-mm`. |\n" + + "| `expireDate` | String | For the push publishing actionlet; " + + "specifies a date to remove the content. Format: `yyyy-MM-dd`. |\n" + + "| `expireTime` | String | For the push publishing actionlet; " + + "specifies a time to remove the content. Format: `hh-mm`. |\n" + + "| `neverExpire` | Boolean | For the push publishing actionlet; " + + "a value of `true` invalidates the expiration time/date. |\n" + + "| `filterKey` | String | For the push publishing actionlet; " + + "specifies a [push publishing filter](https://www.dotcms.com/docs/latest" + + "/push-publishing-filters) key, should the workflow action " + + "call for such. To retrieve a full list of push publishing " + + "filters and their keys, use `GET /v1/pushpublish/filters`. |\n" + + "| `timezoneId` | String | For the push publishing actionlet; " + + "specifies the time zone to which the indicated times belong. " + + "Uses the [tz database](https://www.iana.org/time-zones). " + + "For a list of values, see [the database directly]" + + "(https://data.iana.org/time-zones/tz-link.html) or refer to " + + "[the Wikipedia entry listing tz database time zones]" + + "(https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |\n\n", + content = @Content( + schema = @Schema(implementation = FireMultipleActionForm.class) + ) + ) final FireMultipleActionForm fireActionForm) throws DotDataException, DotSecurityException { final InitDataObject initDataObject = new WebResource.InitBuilder() .requestAndResponse(request, response).requiredAnonAccess(AnonymousAccess.WRITE).init(); @@ -2437,21 +3340,131 @@ private void saveMultipleContentletsByDefaultAction(final List
  • `publish` for " + + "push publish;
  • `expire` for remove;
  • `publishexpire` " + + "for push remove.
  • These are further configurable with the " + + "properties below that specify publishing and expiration " + + "dates, times, etc. |\n" + + "| `publishDate` | String | For the push publishing actionlet; " + + "specifies a date to push the content. Format: `yyyy-MM-dd`. |\n" + + "| `publishTime` | String | For the push publishing actionlet; " + + "specifies a time to push the content. Format: `hh-mm`. |\n" + + "| `expireDate` | String | For the push publishing actionlet; " + + "specifies a date to remove the content. Format: `yyyy-MM-dd`. |\n" + + "| `expireTime` | String | For the push publishing actionlet; " + + "specifies a time to remove the content. Format: `hh-mm`. |\n" + + "| `neverExpire` | Boolean | For the push publishing actionlet; " + + "a value of `true` invalidates the expiration time/date. |\n" + + "| `filterKey` | String | For the push publishing actionlet; " + + "specifies a [push publishing filter](https://www.dotcms.com/docs/latest" + + "/push-publishing-filters) key, should the workflow action " + + "call for such. To retrieve a full list of push publishing " + + "filters and their keys, use `GET /v1/pushpublish/filters`. |\n" + + "| `timezoneId` | String | For the push publishing actionlet; " + + "specifies the time zone to which the indicated times belong. " + + "Uses the [tz database](https://www.iana.org/time-zones). " + + "For a list of values, see [the database directly]" + + "(https://data.iana.org/time-zones/tz-link.html) or refer to " + + "[the Wikipedia entry listing tz database time zones]" + + "(https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |\n\n", + content = @Content( + schema = @Schema(implementation = FireActionForm.class) + ) + ) final FireActionForm fireActionForm) throws DotDataException, DotSecurityException { final InitDataObject initDataObject = new WebResource.InitBuilder() .requestAndResponse(request, response).requiredAnonAccess(AnonymousAccess.WRITE).init(); @@ -2728,7 +3741,12 @@ private void checkContentletState(final Contentlet contentlet, final SystemActio } /** - * Exposed under a different path `firemultipart` so Swagger takes it + * Wrapper function around fireActionMultipart, allowing the `/actions/{actionId}/fire` method receiving + * multipart-form data also to be called from `/actions/{actionId}/firemultipart`. + * Swagger UI doesn't allow endpoint overloading, so this was created as an alias — both to + * surface the endpoint and preserve backwards compatibility. + * The wrapped function receives the @Hidden annotation, which explicitly omits it from the UI. + * All other Swagger-specific annotations have been moved off of the original and on to this one. */ @PUT @@ -2737,21 +3755,78 @@ private void checkContentletState(final Contentlet contentlet, final SystemActio @NoCache @Consumes(MediaType.MULTIPART_FORM_DATA) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation(summary = "Fire action by ID multipart", + @Operation(operationId = "putFireActionByIdMultipart", summary = "Fire action by ID (multipart form) \uD83D\uDEA7", + description = "(**Construction notice:** Still awaiting request body documentation. Coming soon!)\n\n" + + "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + + "specified by identifier, on a target contentlet. Uses a multipart form to transmit its data.\n\n" + + "Returns a map of the resultant contentlet, with an additional " + + "`AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + + "services that handle automatically assigning workflow schemes to content with none.", + tags = {"Workflow"}, responses = { - @ApiResponse( - responseCode = "200", + @ApiResponse(responseCode = "200", description = "Fired action successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "404", description = "Action not found")}) - public final Response fireActionMultipartNewPath(@Context final HttpServletRequest request, + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) + public final Response fireActionMultipartNewPath(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam ("actionId") final String actionId, - @QueryParam("inode") final String inode, - @QueryParam("identifier") final String identifier, - @QueryParam("indexPolicy") final String indexPolicy, - @DefaultValue("-1") @QueryParam("language") final String language, - final FormDataMultiPart multipart) { + @PathParam ("actionId") @Parameter( + required = true, + description = "Identifier of a workflow action.\n\n" + + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + + "(Default system workflow \"Publish\" action)", + schema = @Schema(type = "string") + ) final String actionId, + @QueryParam("inode") @Parameter( + description = "Inode of the target content.", + schema = @Schema(type = "string") + ) final String inode, + @QueryParam("identifier") @Parameter( + description = "Identifier of target content.", + schema = @Schema(type = "string") + ) final String identifier, + @QueryParam("indexPolicy") @Parameter( + description = "Determines how target content is indexed.\n\n" + + "| Value | Description |\n" + + "|-------|-------------|\n" + + "| `DEFER` | Content will be indexed asynchronously, outside of " + + "the current process. Valid content will finish the " + + "method in process and be returned before the content " + + "becomes visible in the index. This is the default " + + "index policy; it is resource-friendly and well-" + + "suited to batch processing. |\n" + + "| `WAIT_FOR` | The API call will not return from the content check " + + "process until the content has been indexed. Ensures content " + + "is promptly available for searching. |\n" + + "| `FORCE` | Forces Elasticsearch to index the content **immediately**.
    " + + "**Caution:** Using this value may cause system performance issues; " + + "it is not recommended for general use, though may be useful " + + "for testing purposes. |\n\n", + schema = @Schema( + type = "string", + allowableValues = {"DEFER", "WAIT_FOR", "FORCE"}, + defaultValue = "" + ) + ) final String indexPolicy, + @DefaultValue("-1") @QueryParam("language") @Parameter( + description = "Language version of target content.", + schema = @Schema(type = "string") + ) final String language, + @RequestBody( + description = "Multipart form. More details to follow.", + required = true, + content = @Content( + schema = @Schema(implementation = FormDataMultiPart.class) + ) + ) final FormDataMultiPart multipart) { return fireActionMultipart(request, response, actionId, inode, identifier, indexPolicy, language, multipart); } @@ -2772,6 +3847,7 @@ public final Response fireActionMultipartNewPath(@Context final Ht @NoCache @Consumes(MediaType.MULTIPART_FORM_DATA) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Hidden public final Response fireActionMultipart(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @PathParam ("actionId") final String actionId, @@ -2816,7 +3892,12 @@ public final Response fireActionMultipart(@Context final HttpServl } // fire. /** - * Exposed under a different path `firemultipart` so Swagger takes it + * Wrapper function around fireActionDefaultMultipart, allowing the `/actions/default/fire/{systemAction}` + * method receiving multipart-form data also to be called from `/actions/default/firemultipart/{systemAction}`. + * Swagger UI doesn't allow endpoint overloading, so this was created as an alias — both to + * surface the endpoint and preserve backwards compatibility. + * The wrapped function receives the @Hidden annotation, which explicitly omits it from the UI. + * All other Swagger-specific annotations have been moved off of the original and on to this one. */ @PUT @Path("/actions/default/firemultipart/{systemAction}") @@ -2824,21 +3905,77 @@ public final Response fireActionMultipart(@Context final HttpServl @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes(MediaType.MULTIPART_FORM_DATA) - @Operation(summary = "Fire default action by name multipart", + @Operation(operationId = "putFireActionByIdMultipart", summary = "Fire action by ID (multipart form) \uD83D\uDEA7", + description = "(**Construction notice:** Still awaiting request body documentation. Coming soon!)\n\n" + + "Fires a default [system action](https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) " + + "on target contentlet. Uses a multipart form to transmit its data.\n\n" + + "Returns a map of the resultant contentlet, with an additional " + + "`AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + + "services that handle automatically assigning workflow schemes to content with none.", + tags = {"Workflow"}, responses = { - @ApiResponse( - responseCode = "200", + @ApiResponse(responseCode = "200", description = "Fired action successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "400", description = "Action not found")}) + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response fireActionDefaultMultipartNewPath( @Context final HttpServletRequest request, @Context final HttpServletResponse response, - @QueryParam("inode") final String inode, - @QueryParam("identifier") final String identifier, - @QueryParam("indexPolicy") final String indexPolicy, - @DefaultValue("-1") @QueryParam("language") final String language, - @PathParam("systemAction") final WorkflowAPI.SystemAction systemAction, + @QueryParam("inode") @Parameter( + description = "Inode of the target content.", + schema = @Schema(type = "string") + ) final String inode, + @QueryParam("identifier") @Parameter( + description = "Identifier of target content.", + schema = @Schema(type = "string") + ) final String identifier, + @QueryParam("indexPolicy") @Parameter( + description = "Determines how target content is indexed.\n\n" + + "| Value | Description |\n" + + "|-------|-------------|\n" + + "| `DEFER` | Content will be indexed asynchronously, outside of " + + "the current process. Valid content will finish the " + + "method in process and be returned before the content " + + "becomes visible in the index. This is the default " + + "index policy; it is resource-friendly and well-" + + "suited to batch processing. |\n" + + "| `WAIT_FOR` | The API call will not return from the content check " + + "process until the content has been indexed. Ensures content " + + "is promptly available for searching. |\n" + + "| `FORCE` | Forces Elasticsearch to index the content **immediately**.
    " + + "**Caution:** Using this value may cause system performance issues; " + + "it is not recommended for general use, though may be useful " + + "for testing purposes. |\n\n", + schema = @Schema( + type = "string", + allowableValues = {"DEFER", "WAIT_FOR", "FORCE"}, + defaultValue = "" + ) + ) final String indexPolicy, + @DefaultValue("-1") @QueryParam("language") @Parameter( + description = "Language version of target content.", + schema = @Schema(type = "string") + ) final String language, + @PathParam("systemAction") @Parameter( + required = true, + schema = @Schema( + type = "string", + allowableValues = { + "NEW", "EDIT", "PUBLISH", + "UNPUBLISH", "ARCHIVE", "UNARCHIVE", + "DELETE", "DESTROY" + } + ), + description = "Default system action." + ) final WorkflowAPI.SystemAction systemAction, final FormDataMultiPart multipart) { return fireActionDefaultMultipart(request, response, inode, identifier, indexPolicy, language, systemAction, multipart); @@ -2859,13 +3996,7 @@ public final Response fireActionDefaultMultipartNewPath( @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes(MediaType.MULTIPART_FORM_DATA) - @Operation(summary = "Fire default action", - responses = { - @ApiResponse( - responseCode = "200", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "400", description = "Action not found")}) + @Hidden public final Response fireActionDefaultMultipart( @Context final HttpServletRequest request, @Context final HttpServletResponse response, @@ -2955,21 +4086,133 @@ public final Response fireActionDefaultMultipart( @NoCache @Consumes({MediaType.APPLICATION_JSON}) @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation(summary = "Fire action by ID", + @Operation(operationId = "putFireActionById", summary = "Fire action by ID", + description = "Fires a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions), " + + "specified by identifier, on a target contentlet.\n\nReturns a map of the resultant contentlet, " + + "with an additional `AUTO_ASSIGN_WORKFLOW` property, which can be referenced by delegate " + + "services that handle automatically assigning workflow schemes to content with none.", + tags = {"Workflow"}, responses = { - @ApiResponse( - responseCode = "200", + @ApiResponse(responseCode = "200", description = "Fired action successfully", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityView.class))), - @ApiResponse(responseCode = "404", description = "Action not found")}) + schema = @Schema(implementation = ResponseEntityView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Content not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response fireActionSinglePart(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam ("actionId") final String actionId, - @QueryParam("inode") final String inode, - @QueryParam("identifier") final String identifier, - @QueryParam("indexPolicy") final String indexPolicy, - @DefaultValue("-1") @QueryParam("language") final String language, - final FireActionForm fireActionForm) throws DotDataException, DotSecurityException { + @PathParam ("actionId") @Parameter( + required = true, + description = "Identifier of a workflow action.\n\n" + + "Example value: `b9d89c80-3d88-4311-8365-187323c96436` " + + "(Default system workflow \"Publish\" action)", + schema = @Schema(type = "string") + ) final String actionId, + @QueryParam("inode") @Parameter( + description = "Inode of the target content.", + schema = @Schema(type = "string") + ) final String inode, + @QueryParam("identifier") @Parameter( + description = "Identifier of target content.", + schema = @Schema(type = "string") + ) final String identifier, + @QueryParam("indexPolicy") @Parameter( + description = "Determines how target content is indexed.\n\n" + + "| Value | Description |\n" + + "|-------|-------------|\n" + + "| `DEFER` | Content will be indexed asynchronously, outside of " + + "the current process. Valid content will finish the " + + "method in process and be returned before the content " + + "becomes visible in the index. This is the default " + + "index policy; it is resource-friendly and well-" + + "suited to batch processing. |\n" + + "| `WAIT_FOR` | The API call will not return from the content check " + + "process until the content has been indexed. Ensures content " + + "is promptly available for searching. |\n" + + "| `FORCE` | Forces Elasticsearch to index the content **immediately**.
    " + + "**Caution:** Using this value may cause system performance issues; " + + "it is not recommended for general use, though may be useful " + + "for testing purposes. |\n\n", + schema = @Schema( + type = "string", + allowableValues = {"DEFER", "WAIT_FOR", "FORCE"}, + defaultValue = "" + ) + ) final String indexPolicy, + @DefaultValue("-1") @QueryParam("language") @Parameter( + description = "Language version of target content.", + schema = @Schema(type = "string") + ) final String language, + @RequestBody( + description = "Optional body consists of a JSON object containing various properties, " + + "some of which are specific to certain actionlets.\n\n" + + "The full list of properties that may be used with this form is as follows:\n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `contentlet` | Object | An alternate way of specifying the target contentlet. " + + "If no identifier or inode is included via parameter, " + + "either one could instead be included in the body as a " + + "property of this object. |\n" + + "| `comments` | String | Comments that will appear in the [workflow tasks]" + + "(https://www.dotcms.com/docs/latest/workflow-tasks) " + + "tool with the execution of this workflow action. |\n" + + "| `individualPermissions` | Object | Allows setting granular permissions associated " + + "with the target. The object properties are the [system names " + + "of permissions](https://www.dotcms.com/docs/latest/user-permissions#Permissions), " + + "such as READ, PUBLISH, EDIT, etc. Their respective values " + + "are a list of user or role identifiers that should be granted " + + "the permission in question. Example: `\"READ\": " + + "[\"9ad24203-ae6a-4e5e-aa10-a8c38fd11f17\",\"MyRole\"]` |\n" + + "| `assign` | String | The identifier of a user or role to next receive the " + + "workflow task assignment. |\n" + + "| `pathToMove` | String | If the workflow action includes the Move actionlet, " + + "this property will specify the target path. This path " + + "must include a host, such as `//default/testfolder`, " + + "`//demo.dotcms.com/application`, etc. |\n" + + "| `query` | String | Not used in this method. |\n" + + "| `whereToSend` | String | For the [push publishing](push-publishing) actionlet; " + + "sets the push-publishing environment to receive the " + + "target content. Must be specified as an environment " + + "identifier. [Learn how to find environment IDs here.]" + + "(https://www.dotcms.com/docs/latest/push-publishing-endpoints#EnvironmentIds) |\n" + + "| `iWantTo` | String | For the push publishing actionlet; " + + "this can be set to one of three values:
    • `publish` for " + + "push publish;
    • `expire` for remove;
    • `publishexpire` " + + "for push remove.
    These are further configurable with the " + + "properties below that specify publishing and expiration " + + "dates, times, etc. |\n" + + "| `publishDate` | String | For the push publishing actionlet; " + + "specifies a date to push the content. Format: `yyyy-MM-dd`. |\n" + + "| `publishTime` | String | For the push publishing actionlet; " + + "specifies a time to push the content. Format: `hh-mm`. |\n" + + "| `expireDate` | String | For the push publishing actionlet; " + + "specifies a date to remove the content. Format: `yyyy-MM-dd`. |\n" + + "| `expireTime` | String | For the push publishing actionlet; " + + "specifies a time to remove the content. Format: `hh-mm`. |\n" + + "| `neverExpire` | Boolean | For the push publishing actionlet; " + + "a value of `true` invalidates the expiration time/date. |\n" + + "| `filterKey` | String | For the push publishing actionlet; " + + "specifies a [push publishing filter](https://www.dotcms.com/docs/latest" + + "/push-publishing-filters) key, should the workflow action " + + "call for such. To retrieve a full list of push publishing " + + "filters and their keys, use `GET /v1/pushpublish/filters`. |\n" + + "| `timezoneId` | String | For the push publishing actionlet; " + + "specifies the time zone to which the indicated times belong. " + + "Uses the [tz database](https://www.iana.org/time-zones). " + + "For a list of values, see [the database directly]" + + "(https://data.iana.org/time-zones/tz-link.html) or refer to " + + "[the Wikipedia entry listing tz database time zones]" + + "(https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |\n\n", + content = @Content( + schema = @Schema(implementation = FireActionForm.class) + ) + ) final FireActionForm fireActionForm) throws DotDataException, DotSecurityException { final InitDataObject initDataObject = new WebResource.InitBuilder() .requestAndResponse(request, response) @@ -3346,11 +4589,41 @@ private Contentlet populateContentlet(final FireActionForm fireActionForm, final @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "putReorderWorkflowActionsInStep", summary = "Change the order of actions within a workflow step", + description = "Updates a [workflow action](https://www.dotcms.com/docs/latest/managing-workflows#Actions)'s " + + "order within a [step](https://www.dotcms.com/docs/latest/managing-workflows#Steps) by assigning it " + + "a numeric order.\n\nReturns \"Ok\" on success.\n\n", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Updated workflow action successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response reorderAction(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("stepId") final String stepId, - @PathParam("actionId") final String actionId, - final WorkflowReorderWorkflowActionStepForm workflowReorderActionStepForm) { + @PathParam("stepId") @Parameter( + required = true, + description = "Identifier of the step containing the action.", + schema = @Schema(type = "string") + ) final String stepId, + @PathParam("actionId") @Parameter( + required = true, + description = "Identifier of the action to reorder.", + schema = @Schema(type = "string") + ) final String actionId, + final @RequestBody( + description = "Body consists of a JSON object containing the single property " + + "`order`, which is assigned an integer value.", + required = true, + content = @Content(schema = @Schema(implementation = WorkflowReorderWorkflowActionStepForm.class)) + ) WorkflowReorderWorkflowActionStepForm workflowReorderActionStepForm) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -3385,9 +4658,40 @@ public final Response reorderAction(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "postImportScheme", summary = "Import a workflow scheme", + description = "Import a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).\n\n" + + "Returns \"OK\" on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Imported workflow scheme successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response importScheme(@Context final HttpServletRequest httpServletRequest, @Context final HttpServletResponse httpServletResponse, - final WorkflowSchemeImportObjectForm workflowSchemeImportForm) { + @RequestBody( + description = "Body consists of a JSON object containing two properties: \n\n" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `workflowObject` | Object | An entire scheme along with steps and actions, " + + "such as received from the corresponding export " + + "method. |\n" + + "| `permissions` | List of Objects | A list of permission objects, such as received " + + "from the corresponding export method. |\n\n" + + "The simplest way to perform an import is to pass the full value of the `entity` property " + + "returned by the corresponding Workflow Scheme Export endpoint as the data payload.", + required = true, + content = @Content( + schema = @Schema(implementation = WorkflowSchemeImportObjectForm.class) + ) + ) final WorkflowSchemeImportObjectForm workflowSchemeImportForm) { final InitDataObject initDataObject = this.webResource.init (null, httpServletRequest, httpServletResponse, true, null); @@ -3436,9 +4740,178 @@ public final Response importScheme(@Context final HttpServletRequest httpServle @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getExportScheme", summary = "Export a workflow scheme", + description = "Export a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).\n\n" + + "Returns the full workflow scheme, along with steps, actions, permissions, etc., on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Exported workflow scheme successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityView.class), + examples = @ExampleObject(value = "{\n" + + " \"entity\": {\n" + + " \"permissions\": [\n" + + " {\n" + + " \"bitPermission\": false,\n" + + " \"id\": 0,\n" + + " \"individualPermission\": true,\n" + + " \"inode\": \"string\",\n" + + " \"permission\": 0,\n" + + " \"roleId\": \"string\",\n" + + " \"type\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"workflowObject\": {\n" + + " \"actionClassParams\": [\n" + + " {\n" + + " \"actionClassId\": \"string\",\n" + + " \"id\": null,\n" + + " \"key\": \"string\",\n" + + " \"value\": null\n" + + " }\n" + + " ],\n" + + " \"actionClasses\": [\n" + + " {\n" + + " \"actionId\": \"string\",\n" + + " \"actionlet\": {\n" + + " \"actionClass\": \"string\",\n" + + " \"howTo\": \"string\",\n" + + " \"localizedHowto\": \"string\",\n" + + " \"localizedName\": \"string\",\n" + + " \"name\": \"string\",\n" + + " \"nextStep\": null,\n" + + " \"parameters\": [\n" + + " {\n" + + " \"defaultValue\": \"\",\n" + + " \"displayName\": \"string\",\n" + + " \"key\": \"string\",\n" + + " \"required\": false\n" + + " }\n" + + " ]\n" + + " },\n" + + " \"clazz\": \"string\",\n" + + " \"id\": \"string\",\n" + + " \"name\": \"string\",\n" + + " \"order\": 0\n" + + " }\n" + + " ],\n" + + " \"actionSteps\": [\n" + + " {\n" + + " \"actionId\": \"string\",\n" + + " \"actionOrder\": \"0\",\n" + + " \"stepId\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"actions\": [\n" + + " {\n" + + " \"assignable\": false,\n" + + " \"commentable\": false,\n" + + " \"condition\": \"\",\n" + + " \"icon\": \"string\",\n" + + " \"id\": \"string\",\n" + + " \"metadata\": null,\n" + + " \"name\": \"string\",\n" + + " \"nextAssign\": \"string\",\n" + + " \"nextStep\": \"string\",\n" + + " \"nextStepCurrentStep\": true,\n" + + " \"order\": 0,\n" + + " \"owner\": null,\n" + + " \"roleHierarchyForAssign\": false,\n" + + " \"schemeId\": \"string\",\n" + + " \"showOn\": []\n" + + " }\n" + + " ],\n" + + " \"schemeSystemActionWorkflowActionMappings\": [\n" + + " {\n" + + " \"identifier\": \"string\",\n" + + " \"owner\": {\n" + + " \"archived\": false,\n" + + " \"creationDate\": 1723806880187,\n" + + " \"defaultScheme\": false,\n" + + " \"description\": \"string\",\n" + + " \"entryActionId\": null,\n" + + " \"id\": \"string\",\n" + + " \"mandatory\": false,\n" + + " \"modDate\": 1723796816309,\n" + + " \"name\": \"string\",\n" + + " \"system\": false,\n" + + " \"variableName\": \"string\"\n" + + " },\n" + + " \"systemAction\": \"string\",\n" + + " \"workflowAction\": {\n" + + " \"assignable\": false,\n" + + " \"commentable\": false,\n" + + " \"condition\": \"\",\n" + + " \"icon\": \"string\",\n" + + " \"id\": \"string\",\n" + + " \"metadata\": null,\n" + + " \"name\": \"string\",\n" + + " \"nextAssign\": \"string\",\n" + + " \"nextStep\": \"string\",\n" + + " \"nextStepCurrentStep\": true,\n" + + " \"order\": 0,\n" + + " \"owner\": null,\n" + + " \"roleHierarchyForAssign\": false,\n" + + " \"schemeId\": \"string\",\n" + + " \"showOn\": []\n" + + " },\n" + + " \"ownerContentType\": false,\n" + + " \"ownerScheme\": true\n" + + " }\n" + + " ],\n" + + " \"schemes\": [\n" + + " {\n" + + " \"archived\": false,\n" + + " \"creationDate\": 1723806880187,\n" + + " \"defaultScheme\": false,\n" + + " \"description\": \"string\",\n" + + " \"entryActionId\": null,\n" + + " \"id\": \"string\",\n" + + " \"mandatory\": false,\n" + + " \"modDate\": 1723796816309,\n" + + " \"name\": \"string\",\n" + + " \"system\": false,\n" + + " \"variableName\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"steps\": [\n" + + " {\n" + + " \"creationDate\": 1723806894533,\n" + + " \"enableEscalation\": false,\n" + + " \"escalationAction\": null,\n" + + " \"escalationTime\": 0,\n" + + " \"id\": \"string\",\n" + + " \"myOrder\": 0,\n" + + " \"name\": \"string\",\n" + + " \"resolved\": false,\n" + + " \"schemeId\": \"string\"\n" + + " }\n" + + " ],\n" + + " \"version\": \"string\"\n" + + " }\n" + + " },\n" + + " \"errors\": [],\n" + + " \"i18nMessagesMap\": {},\n" + + " \"messages\": [],\n" + + " \"pagination\": null,\n" + + " \"permissions\": []\n" + + "}") + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response exportScheme(@Context final HttpServletRequest httpServletRequest, @Context final HttpServletResponse httpServletResponse, - @PathParam("schemeIdOrVariable") final String schemeIdOrVariable) { + @PathParam("schemeIdOrVariable") @Parameter( + required = true, + description = "Identifier or variable name of the workflow scheme to export.", + schema = @Schema(type = "string") + ) final String schemeIdOrVariable) { final InitDataObject initDataObject = this.webResource.init (null, httpServletRequest, httpServletResponse,true, null); @@ -3482,11 +4955,49 @@ public final Response exportScheme(@Context final HttpServletRequest httpServle @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "postCopyScheme", summary = "Copy a workflow scheme", + description = "Copy a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).\n\n " + + "A name for the new scheme may be provided either by parameter or by POST body property; if no name " + + "is supplied, the name will be that of the copied workflow scheme with the current Unix epoch " + + "timestamp integer appended.\n\n" + + "Returns copied workflow scheme on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Copied workflow scheme successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowSchemeView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response copyScheme(@Context final HttpServletRequest httpServletRequest, @Context final HttpServletResponse httpServletResponse, - @PathParam("schemeId") final String schemeId, - @QueryParam("name") final String name, - final WorkflowCopyForm workflowCopyForm) { + @PathParam("schemeId") @Parameter( + required = true, + description = "Identifier of workflow scheme.\n\n" + + "Example value: `d61a59e1-a49c-46f2-a929-db2b4bfa88b2` " + + "(Default system workflow)", + schema = @Schema(type = "string") + ) final String schemeId, + @QueryParam("name") @Parameter( + description = "Name of new scheme from copy.\n\nNote: A name with a length " + + "less than 2 characters or greater than 100 may require renaming before " + + "certain actions, such as archiving, can be taken on it.", + schema = @Schema(type = "string") + ) final String name, + @RequestBody( + description = "Body consists of a `name` property; an alternate way to supply " + + "the name of the new scheme, instead of parameter.\n\n Name supplied " + + "this way must be at minimum 2 and at maximum 100 characters in length.", + content = @Content( + schema = @Schema(implementation = WorkflowCopyForm.class) + ) + ) final WorkflowCopyForm workflowCopyForm) { final InitDataObject initDataObject = this.webResource.init (null, httpServletRequest, httpServletResponse,true, null); @@ -3528,9 +5039,33 @@ public final Response copyScheme(@Context final HttpServletRequest httpServletRe @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getDefaultActionsByContentTypeId", summary = "Find possible default actions by content type", + description = "Returns a list of actions that may be used as a [default action]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) for a " + + "specified [content type](https://www.dotcms.com/docs/latest/content-types), along with their " + + "associated [workflow schemes](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Default action(s) returned successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityDefaultWorkflowActionsView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Content type not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response findAvailableDefaultActionsByContentType(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("contentTypeId") final String contentTypeId) { + @PathParam("contentTypeId") @Parameter( + required = true, + description = "Identifier or variable of content type to examine for actions.\n\n" + + "Example ID: `c541abb1-69b3-4bc5-8430-5e09e5239cc8` (Default page content type)\n\n" + + "Example Variable: `htmlpageasset` (Default page content type)", + schema = @Schema(type = "string") + ) final String contentTypeId) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); try { @@ -3559,10 +5094,31 @@ public final Response findAvailableDefaultActionsByContentType(@Context final Ht @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getDefaultActionsBySchemeIds", summary = "Find possible default actions by scheme(s)", + description = "Returns a list of actions that are eligible to be used as a [default action]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#DefaultActions) for one or " + + "more [workflow schemes](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Action(s) returned successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityDefaultWorkflowActionsView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow action not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response findAvailableDefaultActionsBySchemes( @Context final HttpServletRequest request, @Context final HttpServletResponse response, - @QueryParam("ids") final String schemeIds) { + @QueryParam("ids") @Parameter( + required = true, + description = "Comma-separated list of workflow scheme identifiers.", + schema = @Schema(type = "string") + ) String schemeIds) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -3595,10 +5151,32 @@ public final Response findAvailableDefaultActionsBySchemes( @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getInitialActionsByContentTypeId", summary = "Find initial actions by content type", + description = "Returns a list of available actions of the initial/first step(s) of the workflow scheme(s) " + + "associated with a [content type](https://www.dotcms.com/docs/latest/content-types).", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Initial action(s) returned successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityDefaultWorkflowActionsView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Content type not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response findInitialAvailableActionsByContentType( @Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("contentTypeId") final String contentTypeId) { + @PathParam("contentTypeId") @Parameter( + required = true, + description = "Identifier or variable of content type to examine for initial actions.\n\n" + + "Example ID: `c541abb1-69b3-4bc5-8430-5e09e5239cc8` (Default page content type)\n\n" + + "Example Variable: `htmlpageasset` (Default page content type)", + schema = @Schema(type = "string") + ) final String contentTypeId) { final InitDataObject initDataObject = this.webResource.init (null, request, response, true, null); @@ -3631,9 +5209,36 @@ public final Response findInitialAvailableActionsByContentType( @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "postSaveScheme", summary = "Create a workflow scheme", + description = "Create a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).\n\n " + + "Returns created workflow scheme on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Copied workflow scheme successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowSchemeView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` + @ApiResponse(responseCode = "401", description = "Invalid User"), // not logged in + @ApiResponse(responseCode = "403", description = "Forbidden"), // no permission + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response saveScheme(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - final WorkflowSchemeForm workflowSchemeForm) { + @RequestBody( + description = "The request body consists of the following three properties:" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `schemeName` | String | The workflow scheme's name. |\n" + + "| `schemeDescription` | String | A description of the scheme. |\n" + + "| `schemeArchived` | Boolean | If `true`, the scheme will be created " + + "in an archived state. |\n", + content = @Content( + schema = @Schema(implementation = WorkflowSchemeForm.class) + ) + ) final WorkflowSchemeForm workflowSchemeForm) { final InitDataObject initDataObject = this.webResource.init(null, request, response, true, null); try { DotPreconditions.notNull(workflowSchemeForm,"Expected Request body was empty."); @@ -3660,10 +5265,43 @@ public final Response saveScheme(@Context final HttpServletRequest request, @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) @Consumes({MediaType.APPLICATION_JSON}) + @Operation(operationId = "putUpdateWorkflowScheme", summary = "Update a workflow scheme", + description = "Updates a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes).\n\n" + + "Returns updated scheme on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Updated workflow scheme successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad request"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow scheme not found."), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final Response updateScheme(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("schemeId") final String schemeId, - final WorkflowSchemeForm workflowSchemeForm) { + @PathParam("schemeId") @Parameter( + required = true, + description = "Identifier of workflow scheme.\n\n" + + "Example value: `d61a59e1-a49c-46f2-a929-db2b4bfa88b2` (Default system workflow)", + schema = @Schema(type = "string") + ) final String schemeId, + @RequestBody( + description = "The request body consists of the following three properties:" + + "| Property | Type | Description |\n" + + "|-|-|-|\n" + + "| `schemeName` | String | The workflow scheme's name. |\n" + + "| `schemeDescription` | String | A description of the scheme. |\n" + + "| `schemeArchived` | Boolean | If `true`, the scheme will be be placed " + + "in an archived state. |\n", + content = @Content( + schema = @Schema(implementation = WorkflowSchemeForm.class) + ) + ) final WorkflowSchemeForm workflowSchemeForm) { final InitDataObject initDataObject = this.webResource.init(null, request, response, true, null); Logger.debug(this, "Updating scheme with id: " + schemeId); try { @@ -3687,9 +5325,30 @@ public final Response updateScheme(@Context final HttpServletRequest request, @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "deleteWorkflowSchemeById", summary = "Delete a workflow scheme", + description = "Deletes a [workflow scheme](https://www.dotcms.com/docs/latest/managing-workflows#Schemes)\n\n" + + "Scheme must already be in an archived state.\n\n" + + "Returns deleted workflow scheme on success.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Workflow scheme deleted successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityWorkflowSchemeView.class) + ) + ), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Workflow scheme not found"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") + } + ) public final void deleteScheme(@Context final HttpServletRequest request, @Suspended final AsyncResponse asyncResponse, - @PathParam("schemeId") final String schemeId) { + @PathParam("schemeId") @Parameter( + required = true, + description = "Identifier of workflow scheme to delete.", + schema = @Schema(type = "string") + ) final String schemeId) { final InitDataObject initDataObject = this.webResource.init(null, request,new EmptyHttpResponse(), true, null); Logger.debug(this, ()-> "Deleting scheme with id: " + schemeId); @@ -3735,9 +5394,33 @@ public final void deleteScheme(@Context final HttpServletRequest request, @JSONP @NoCache @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getContentWorkflowStatusByInode", summary = "Find workflow status of content", + description = "Checks the current workflow status of a contentlet by its [inode]" + + "(https://www.dotcms.com/docs/latest/content-versions#IdentifiersInodes).\n\n" + + "Returns an object containing the associated [workflow scheme]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#Schemes), [workflow step]" + + "(https://www.dotcms.com/docs/latest/managing-workflows#Steps), and [workflow task]" + + "(https://www.dotcms.com/docs/latest/workflow-tasks) associated with the contentlet.", + tags = {"Workflow"}, + responses = { + @ApiResponse(responseCode = "200", description = "Action(s) returned successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseContentletWorkflowStatusView.class) + ) + ), + @ApiResponse(responseCode = "400", description = "Bad Requesy"), + @ApiResponse(responseCode = "401", description = "Invalid User"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "500", description = "Internal Server Error") // includes when inode not found + } + ) public final ResponseContentletWorkflowStatusView getStatusForContentlet(@Context final HttpServletRequest request, @Context final HttpServletResponse response, - @PathParam("contentletInode") final String contentletInode) + @PathParam("contentletInode") @Parameter( + required = true, + description = "Inode of content version to inspect for workflow status.\n\n", + schema = @Schema(type = "string") + ) final String contentletInode) throws DotDataException, DotSecurityException, InvocationTargetException, IllegalAccessException { Logger.debug(this, String.format("Retrieving Workflow status for Contentlet with Inode " + "'%s'", contentletInode)); diff --git a/dotCMS/src/main/java/com/dotcms/util/FunctionUtils.java b/dotCMS/src/main/java/com/dotcms/util/FunctionUtils.java index 39fcbca9870..04b611b74ea 100644 --- a/dotCMS/src/main/java/com/dotcms/util/FunctionUtils.java +++ b/dotCMS/src/main/java/com/dotcms/util/FunctionUtils.java @@ -14,6 +14,19 @@ */ public class FunctionUtils { + /** + * Get the value if the condition is true, otherwise return the default value. + * @param condition + * @param trueSupplier + * @param falseSupplier + * @return + * @param + */ + public static T getOrDefault(final boolean condition, final Supplier trueSupplier, final Supplier falseSupplier) { + + return condition?trueSupplier.get():falseSupplier.get(); + } // getOrDefault. + /** * The idea behind this method is to concat a consequent callback if value is true. * For instance diff --git a/dotCMS/src/main/java/com/dotcms/util/TimeMachineUtil.java b/dotCMS/src/main/java/com/dotcms/util/TimeMachineUtil.java index 2cdb16d4ec8..f5504dbaa90 100644 --- a/dotCMS/src/main/java/com/dotcms/util/TimeMachineUtil.java +++ b/dotCMS/src/main/java/com/dotcms/util/TimeMachineUtil.java @@ -14,6 +14,9 @@ private TimeMachineUtil(){} * @return */ public static Optional getTimeMachineDate() { + if (null == HttpServletRequestThreadLocal.INSTANCE.getRequest()) { + return Optional.empty(); + } final HttpSession session = HttpServletRequestThreadLocal.INSTANCE.getRequest().getSession(false); final Object timeMachineObject = session != null ? session.getAttribute("tm_date") : null; return Optional.ofNullable(timeMachineObject != null ? timeMachineObject.toString() : null); diff --git a/dotCMS/src/main/java/com/dotcms/visitor/filter/characteristics/BaseCharacter.java b/dotCMS/src/main/java/com/dotcms/visitor/filter/characteristics/BaseCharacter.java index 30d148f468b..195053f7c4c 100644 --- a/dotCMS/src/main/java/com/dotcms/visitor/filter/characteristics/BaseCharacter.java +++ b/dotCMS/src/main/java/com/dotcms/visitor/filter/characteristics/BaseCharacter.java @@ -3,7 +3,6 @@ import com.dotcms.enterprise.cluster.ClusterFactory; import com.dotcms.uuid.shorty.ShortyIdAPI; import com.dotcms.visitor.domain.Visitor; - import com.dotcms.visitor.filter.servlet.VisitorFilter; import com.dotmarketing.beans.Host; import com.dotmarketing.beans.Identifier; @@ -16,14 +15,13 @@ import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.util.WebKeys; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.Optional; import java.util.UUID; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - public class BaseCharacter extends AbstractCharacter { private final static String CLUSTER_ID; @@ -58,7 +56,8 @@ private BaseCharacter(final HttpServletRequest request, final HttpServletRespons final Optional content = Optional.ofNullable((String) request.getAttribute(WebKeys.WIKI_CONTENTLET)); final Language lang = WebAPILocator.getLanguageWebAPI().getLanguage(request); - IAm iAm = resolveResourceType(uri, getHostNoThrow(request), lang.getId()); + final IAm iAm = resolveResourceType(uri, getHostNoThrow(request), lang.getId()); + final Long pageProcessingTime = (Long) request.getAttribute(VisitorFilter.DOTPAGE_PROCESSING_TIME); myMap.get().put("id", UUID.randomUUID().toString()); myMap.get().put("status", response.getStatus()); @@ -75,11 +74,14 @@ private BaseCharacter(final HttpServletRequest request, final HttpServletRespons myMap.get().put("mime", response.getContentType()); myMap.get().put("vanityUrl", (String) request.getAttribute(VisitorFilter.VANITY_URL_ATTRIBUTE)); myMap.get().put("referer", request.getHeader("referer")); + myMap.get().put("user-agent", request.getHeader("user-agent")); myMap.get().put("host", request.getHeader("host")); myMap.get().put("assetId", assetId); myMap.get().put("contentId", content.orElse(null)); myMap.get().put("lang", lang.toString()); + myMap.get().put("langId", lang.getId()); + myMap.get().put("src", "dotCMS"); } public BaseCharacter(final HttpServletRequest request, final HttpServletResponse response) { @@ -97,31 +99,34 @@ private Host getHostNoThrow(HttpServletRequest req) { } - - - private IAm resolveResourceType(final String uri, final Host site, final long languageId) { + /** + * This method will resolve the resource type of the request + * @param uri + * @param site + * @param languageId + * @return IAm + */ + public static IAm resolveResourceType(final String uri, final Host site, final long languageId) { if(uri!=null) { - if(uri.startsWith("/dotAsset/") || uri.startsWith("/contentAsset") || uri.startsWith("/dA") || uri.startsWith("/DOTLESS")|| uri.startsWith("/DOTSASS")) { + if(isFilePreffixOrSuffix(uri)) { return IAm.FILE; } } - - - - - - if (CMSUrlUtil.getInstance().isFileAsset(uri, site, languageId)) { - return IAm.FILE; - } else if (CMSUrlUtil.getInstance().isPageAsset(uri, site, languageId)) { - return IAm.PAGE; - } else if (CMSUrlUtil.getInstance().isFolder(uri, site)) { - return IAm.FOLDER; - } else { - return IAm.NOTHING_IN_THE_CMS; - } + return CMSUrlUtil.getInstance().resolveResourceType(IAm.NOTHING_IN_THE_CMS, uri, + site, languageId)._1; + + } + + private static boolean isFilePreffixOrSuffix(String uri) { + return uri.startsWith("/dotAsset/") || + uri.startsWith("/contentAsset") || + uri.startsWith("/dA") || + uri.startsWith("/DOTLESS") || + uri.startsWith("/DOTSASS") || + uri.endsWith(".dotsass"); } diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java index 87d2f78245a..4e951825d18 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java @@ -40,7 +40,7 @@ private void addInterceptors(final FilterConfig config) { delegate.add(new ResponseMetaDataWebInterceptor()); delegate.add(new EventLogWebInterceptor()); delegate.add(new CurrentVariantWebInterceptor()); - delegate.add(new AnalyticsTrackWebInterceptor()); + //delegate.add(new AnalyticsTrackWebInterceptor()); // turn on when needed. } // addInterceptors. } // E:O:F:InterceptorFilter. diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPI.java b/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPI.java index d5606331ca4..ae23bbae6c6 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPI.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPI.java @@ -422,5 +422,14 @@ public List findFileAssetsByParentable(final Parentable parent, * @param fileAssetFilter {@link FileListener} */ void subscribeFileListener (final FileListener fileListener, final Predicate fileAssetFilter); - + + /** + * Finds a File Asset by Path + * @param uri + * @param site + * @param languageId + * @param live + * @return + */ + FileAsset getFileByPath(String uri, Host site, long languageId, boolean live); } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPIImpl.java index 4d6726c1739..0f4d741b321 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPIImpl.java @@ -4,8 +4,10 @@ import com.dotcms.browser.BrowserQuery; import com.dotcms.content.elasticsearch.business.event.ContentletCheckinEvent; import com.dotcms.content.elasticsearch.business.event.ContentletDeletedEvent; +import com.dotcms.contenttype.model.type.BaseContentType; import com.dotcms.system.event.local.business.LocalSystemEventsAPI; import com.dotcms.system.event.local.model.EventSubscriber; +import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo; import com.dotmarketing.portlets.folders.business.FolderAPIImpl; import com.dotmarketing.portlets.structure.model.Field.DataType; import java.io.ByteArrayInputStream; @@ -17,6 +19,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; @@ -879,4 +883,41 @@ private void triggerModifiedEvent(ContentletCheckinEvent event, FileListener fil Logger.debug(this, e.getMessage(), e); } } + + @Override + public FileAsset getFileByPath(final String uri, final Host site, + final long languageId, final boolean live) { + + FileAsset fileAsset = null; + + if (Objects.nonNull(site)) { + + Logger.debug(this, ()-> "Getting the file by path: " + uri + " for host: " + site.getHostname()); + try { + + final Identifier identifier = APILocator.getIdentifierAPI().find(site, uri); + final Optional cinfo = APILocator.getVersionableAPI() + .getContentletVersionInfo(identifier.getId(), languageId); + + if (cinfo.isPresent()) { + + final ContentletVersionInfo versionInfo = cinfo.get(); + final Contentlet contentlet = APILocator.getContentletAPI() + .find(live ? versionInfo.getLiveInode() : versionInfo.getWorkingInode(), + APILocator.systemUser(), false); + if (contentlet.getContentType().baseType() == BaseContentType.FILEASSET) { + + fileAsset = fromContentlet(contentlet); + } + } + } catch (DotDataException | DotSecurityException e) { + + Logger.error(this, "Error getting the fileasset for the path: " + + uri + " for host: " + site.getHostname() + ", msg: " + e.getMessage(), e); + throw new DotRuntimeException(e.getMessage(), e); + } + } + + return fileAsset; + } } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3e72df39464..9846c007883 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1176,6 +1176,31 @@ dot.binary.field.file.dimension=Dimension dot.binary.field.file.bytes=Bytes dot.binary.field.import.from.url.error.file.not.supported.message=This type of file is not supported, Please import a {0} file. dot.binary.field.no.link.found=No link found +dot.file.field.action.choose.file=Choose File +dot.file.field.action.select.existing.file=Select Existing File +dot.file.field.action.create.new.file=Create New File +dot.file.field.action.create.new.file.label=File Name +dot.file.field.action.generate.with.dotai=Generate With dotAI +dot.file.field.action.generate.with.tooltip=Please configure dotAI to enable this feature +dot.file.field.action.import.from.url=Import from URL +dot.file.field.action.import.from.url.error.message=The URL you requested is not valid. Please try again. +dot.file.field.action.remove=Remove +dot.file.field.dialog.create.new.file.header=File Details +dot.file.field.dialog.import.from.url.header=URL +dot.file.field.dialog.generate.from.ai.header=Generate AI Image +dot.file.field.drag.and.drop.message=Drag and Drop or +dot.file.field.drag.and.drop.error.could.not.load.message=Couldn't load the file. Please try again or +dot.file.field.drag.and.drop.error.file.not.supported.message=This type of file is not supported, Please select a {0} file. +dot.file.field.drag.and.drop.error.multiple.files.dropped.message=You can only upload one file at a time. +dot.file.field.drag.and.drop.error.file.maxsize.exceeded.message=The file weight exceeds the limits of {0}, please reduce size before uploading. +dot.file.field.drag.and.drop.error.server.error.message=Something went wrong, please try again or contact our support team. +dot.file.field.error.type.file.not.supported.message=This type of file is not supported. Please use a {0} file. +dot.file.field.error.type.file.not.extension=Please add the file's extension +dot.file.field.file.size=File Size +dot.file.field.file.dimension=Dimension +dot.file.field.file.bytes=Bytes +dot.file.field.import.from.url.error.file.not.supported.message=This type of file is not supported, Please import a {0} file. +dot.file.field.no.link.found=No link found dot.common.apply=Apply dot.common.archived=Archived dot.common.cancel=Cancel diff --git a/dotCMS/src/main/webapp/html/css/dijit-dotcms/dotcms.css b/dotCMS/src/main/webapp/html/css/dijit-dotcms/dotcms.css index 348fa4439a3..58d066277f1 100644 --- a/dotCMS/src/main/webapp/html/css/dijit-dotcms/dotcms.css +++ b/dotCMS/src/main/webapp/html/css/dijit-dotcms/dotcms.css @@ -5320,7 +5320,7 @@ h3, h4, h5, h6 { - font-size: 100%; + font-size: 0.875rem; font-weight: normal; } @@ -5808,32 +5808,33 @@ body { } h1 { - font-size: 174%; + font-size: 1.5rem; } h2 { - font-size: 138.5%; + font-size: 1.25rem; line-height: 115%; margin: 0 0 0.2em 0; font-weight: normal; } h3 { - font-size: 100%; + font-size: 0.875rem; margin: 0 0 0.2em 0; font-weight: bold; } h4 { + font-size: 0.875rem; font-weight: bold; } h5 { - font-size: 77%; + font-size: 0.75rem; } h6 { - font-size: 77%; + font-size: 0.75rem; font-style: italic; } @@ -5893,7 +5894,7 @@ ul li { } .inputCaption { - font-size: 85%; + font-size: 0.75rem; color: #888; font-style: italic; } @@ -5919,12 +5920,12 @@ kbd, samp, tt { font-family: monospace; - *font-size: 108%; + *font-size: 1rem; line-height: 99%; } sup { - font-size: 60%; + font-size: 0.625rem; } abbr { @@ -6032,7 +6033,7 @@ select[multiple]:hover { position: absolute; top: 4px; right: 30px; - font-size: 85%; + font-size: 0.75rem; color: #ddd; } @@ -6124,7 +6125,7 @@ select[multiple]:hover { right: 30px; top: 34px; width: 225px; - font-size: 85%; + font-size: 0.75rem; border: 1px solid #d1d4db; border-top: 0; background: #fff; @@ -6233,7 +6234,7 @@ select[multiple]:hover { .changeHost { cursor: pointer; float: right; - font-size: 85%; + font-size: 0.75rem; line-height: 15px; margin: 6px 10px 0 0; padding: 0; @@ -6442,7 +6443,7 @@ tr.active { .excelDownload { text-align: right; padding: 5px 10px; - font-size: 85%; + font-size: 0.75rem; } .excelDownload a { @@ -6631,7 +6632,7 @@ tr.active { } .tagsBox a { - font-size: 93%; + font-size: 0.875rem; color: #999; } @@ -6639,7 +6640,7 @@ tr.active { display: block; color: #999; line-height: 140%; - font-size: 80%; + font-size: 0.75rem; margin: 3px 0 0 5px; } @@ -7354,7 +7355,7 @@ table.sTypeTable.sTypeItem { } .siteOverview span { - font-size: 300%; + font-size: 48px; display: block; padding: 8px; } @@ -7376,7 +7377,7 @@ table.dojoxLegendNode td { #pieChartLegend td.dojoxLegendText { text-align: left; vertical-align: top; - font-size: 85%; + font-size: 0.75rem; line-height: 131%; } @@ -7401,7 +7402,7 @@ table.dojoxLegendNode td { border-radius: 10px; color: #fff; text-align: center; - font-size: 131%; + font-size: 1.25rem; } .noPie { @@ -7669,7 +7670,7 @@ div#_dotHelpMenu { background: #fff; color: #5f5f5f; display: block; - font-size: 85%; + font-size: 0.75rem; margin: 0; padding: 3px 10px; vertical-align: middle; @@ -7717,7 +7718,7 @@ div#_dotHelpMenu { } .navbar .navMenu-title { color: #404040; - font-size: 93%; + font-size: 0.875rem; font-weight: 700; line-height: 14px; margin: 0; @@ -7726,7 +7727,7 @@ div#_dotHelpMenu { } .navbar .navMenu-subtitle { color: #5f5f5f; - font-size: 77%; + font-size: 0.625rem; margin: 0; overflow: hidden; padding: 0; @@ -9492,7 +9493,7 @@ dd .buttonCaption { .dayEventsSection span { font-weight: bold; text-transform: uppercase; - font-size: 77%; + font-size: 0.625rem; } /* NAV MENU STYLES */ @@ -9548,7 +9549,7 @@ dd .buttonCaption { #eventDetailTitle { font-weight: bold; - font-size: 1.2em; + font-size: 1rem; padding-bottom: 5px; width: 459; overflow: hidden; diff --git a/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/system_config.jsp b/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/system_config.jsp index 26cca138f3b..0aa9896f8b4 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/system_config.jsp +++ b/dotCMS/src/main/webapp/html/portlet/ext/cmsmaintenance/system_config.jsp @@ -7,7 +7,7 @@ #anchorTOCDiv { max-width:600px; margin: 20px; - font-size: 2em; + font-size: 1.5rem; display:grid; text-align: center; grid-gap: 10px; @@ -16,7 +16,7 @@ } #anchorTOCDiv div{ - font-size: .7em; + font-size: 1rem; text-transform: capitalize; } @@ -35,7 +35,7 @@ } .propLabel{ font-weight: normal; - font-size:24px; + font-size:1.25rem; text-transform: capitalize; padding:10px; diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html index ee38a6073d5..222157f5c26 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_accordion_contentType_entry.html @@ -9,6 +9,6 @@ - ${permissionsOnContentTypeChildren} + ${permissionsOnContentTypeChildren} diff --git a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_inc.jsp b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_inc.jsp index af28805a981..d26414ffb4f 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_inc.jsp +++ b/dotCMS/src/main/webapp/html/portlet/ext/common/edit_permissions_tab_inc.jsp @@ -12,7 +12,7 @@