diff --git a/.github/workflows/issue_comp_next-release-label.yml b/.github/workflows/issue_comp_next-release-label.yml index c7a8fb703765..2501d7875035 100644 --- a/.github/workflows/issue_comp_next-release-label.yml +++ b/.github/workflows/issue_comp_next-release-label.yml @@ -36,14 +36,14 @@ jobs: let issueNumber; const issue = context.payload.issue; if (!issue && '${{ inputs.issue_number }}'.trim() === '') { - core.warn('Issue number is not provided'); + core.warning('Issue number is not provided'); process.exit(0); } let label = context.payload.label; if (!label) { if (!'${{ inputs.label }}') { - core.warn('Label is missing, exiting'); + core.warning('Label is missing, exiting'); process.exit(0); } label = '${{ inputs.label }}'; @@ -52,7 +52,7 @@ jobs: } if (label !== '${{ env.QA_NOT_NEEDED_LABEL }}') { - core.warn('Label is not [${{ env.QA_NOT_NEEDED_LABEL }}], exiting'); + core.warning('Label is not [${{ env.QA_NOT_NEEDED_LABEL }}], exiting'); process.exit(0); } @@ -82,7 +82,7 @@ jobs: } if (!issue) { - core.warn('Issue [${{ inputs.issue_number }}] not found'); + core.warning('Issue [${{ inputs.issue_number }}] not found'); process.exit(0); } } @@ -92,14 +92,14 @@ jobs: const dropAndLearnText = 'Drop Everything & Learn'.toLowerCase(); if (issue.title.toLowerCase().includes(dropAndLearnText)) { - core.warn(`Issue does have "${dropAndLearnText}" text in title, exiting`); + 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) { - core.warn(`Issue does have "${typeCicdLabel}" label , exiting`); + core.warning(`Issue does have "${typeCicdLabel}" label , exiting`); process.exit(0); } diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 9d52d1d8a315..7c0ecb3ac7fd 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/apps/dotcms-ui/proxy-dev.conf.mjs b/core-web/apps/dotcms-ui/proxy-dev.conf.mjs index 81ea26bc64f6..bda865f0d935 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 6b5569fc07a2..000000000000 --- 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 afa30140a044..79e92f49e120 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/libs/dot-rules/src/lib/styles/rule-engine.scss b/core-web/libs/dot-rules/src/lib/styles/rule-engine.scss index 7255ffc367f5..0a61153ce132 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 6724ac1ccefa..a4144292c458 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 f3fcd834316e..4879c7e561aa 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 3ce236a90886..d77491a73559 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 8e8e75f7ebfb..12f280dbe71f 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 f89147fc6a1b..63bd69f9fb94 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 c499fc1727d7..16d355677c86 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 4a8758b2b9ce..fffdfa85630e 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 1f03ca304fb4..cd6f7c4e8164 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 267f18ddff53..6f9ec8093bb9 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 305db1c9f635..71722e39c27f 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/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 000000000000..bc37b961960f --- /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 000000000000..a58223fb3de1 --- /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 000000000000..9f81f7936c3c --- /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 000000000000..d9302f0405ff --- /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 000000000000..4d9a965cd274 --- /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 000000000000..e69de29bb2d1 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 000000000000..57aa9790ca13 --- /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 000000000000..5f226a4d1ceb --- /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 000000000000..be2aab475a07 --- /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 000000000000..b3690e7d9ca1 --- /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 51c32d010e29..b65c81ec23cf 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 a61ef6345a10..26867d40f9bd 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 ce3b3500a924..f4916eaa8a40 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 8cd1f9400bf8..43e5d6a4af81 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 000000000000..30f3f47be892 --- /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/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 eb9981516db4..542690f5a246 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/utils-testing/src/index.ts b/core-web/libs/utils-testing/src/index.ts index 2bd48258ae7e..9bace464fafb 100644 --- a/core-web/libs/utils-testing/src/index.ts +++ b/core-web/libs/utils-testing/src/index.ts @@ -47,3 +47,4 @@ export * from './lib/seo-mock'; export * from './lib/dot-message-mock.pipe'; export * from './lib/push-publish.mock'; export * from './lib/dot-license-service.mock'; +export * from './lib/monaco-editor.mock'; diff --git a/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts b/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts new file mode 100644 index 000000000000..335ea88d50c4 --- /dev/null +++ b/core-web/libs/utils-testing/src/lib/monaco-editor.mock.ts @@ -0,0 +1,81 @@ +export const monacoMock = { + editor: { + create: () => ({ + 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 9e0ecd25eb9b..f6aada775d43 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 b264b89d03e9..0563719b8027 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 000000000000..5c42b1f02a99 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/analytics/Util.java @@ -0,0 +1,37 @@ +package com.dotcms.analytics; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.cms.urlmap.UrlMapContext; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; +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/track/AnalyticsTrackWebInterceptor.java b/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java index 64b448b6e290..c5dc42606959 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 000000000000..7dcd96bf15a7 --- /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 000000000000..3ab4a74277e7 --- /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 000000000000..f49a5850f411 --- /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 000000000000..940f000a5c27 --- /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 000000000000..bae69a850e3b --- /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 000000000000..12164a68544b --- /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 000000000000..bd25853f5ef4 --- /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 000000000000..7035ed75feae --- /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 000000000000..9408ab91420e --- /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 000000000000..9fd42684caaa --- /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 000000000000..ce2f62c642e2 --- /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 000000000000..46ebe9ebac2e --- /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 000000000000..f25cff71be0d --- /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 000000000000..388256b7bd0d --- /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 000000000000..00054371f094 --- /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 000000000000..1d0b8f68ebe8 --- /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 d5067670615d..a18b01f14f0c 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 df1e24b5c64d..374ddaab70d2 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 0cbd764d1c3a..022dee6ed985 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 a32c4badccfe..f150ea3af468 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 433d8a2853da..df7be86e702c 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/jitsu/AnalyticsEventsPayload.java b/dotCMS/src/main/java/com/dotcms/jitsu/AnalyticsEventsPayload.java new file mode 100644 index 000000000000..e6d65aff50db --- /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 268cfc30bbf0..68246893593f 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 1b6e27335634..810484dab406 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 10817ded19b1..833eb6cf53f1 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/util/FunctionUtils.java b/dotCMS/src/main/java/com/dotcms/util/FunctionUtils.java index 39fcbca98703..04b611b74ea3 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 2cdb16d4ec89..f5504dbaa90d 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 30d148f468b7..195053f7c4c0 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/portlets/fileassets/business/FileAssetAPI.java b/dotCMS/src/main/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPI.java index d5606331ca45..ae23bbae6c60 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 4d6726c17396..0f4d741b3210 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/html/css/dijit-dotcms/dotcms.css b/dotCMS/src/main/webapp/html/css/dijit-dotcms/dotcms.css index 348fa4439a37..58d066277f15 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 26cca138f3bc..0aa9896f8b43 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 ee38a6073d59..222157f5c26e 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 af28805a9816..d26414ffb4f0 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 @@