From 8ff8b2bef3c34052797c6b73e463fc4769b4831f Mon Sep 17 00:00:00 2001 From: zachary Date: Fri, 10 Apr 2026 15:54:07 -0600 Subject: [PATCH 1/8] Add advanced GA4 matching and validation guide --- apps/google-analytics-4/README.md | 2 + apps/google-analytics-4/VALIDATION.md | 479 ++++++++++++++++++ .../frontend/src/apis/api.spec.ts | 18 + .../frontend/src/apis/api.ts | 5 +- .../GoogleAnalyticsConfigPage.tsx | 19 +- .../AssignContentType.styles.ts | 82 ++- .../AssignContentTypeCard.spec.tsx | 27 +- .../AssignContentTypeCard.tsx | 55 +- .../AssignContentTypeRow.spec.tsx | 243 ++++++--- .../AssignContentTypeRow.tsx | 404 ++++++++++----- .../AssignContentTypeSection.spec.tsx | 1 + .../AssignContentTypeSection.tsx | 113 +++-- .../AnalyticsApp/AnalyticsApp.spec.tsx | 34 +- .../main-app/AnalyticsApp/AnalyticsApp.tsx | 148 ++++-- .../AnalyticsMetricDisplay.spec.tsx | 4 + .../AnalyticsMetricDisplay.tsx | 28 +- .../ChartContent/ChartContent.spec.tsx | 6 + .../ChartContent/ChartContent.styles.ts | 3 + .../main-app/ChartContent/ChartContent.tsx | 8 +- .../main-app/ChartFooter/ChartFooter.spec.tsx | 16 + .../main-app/ChartFooter/ChartFooter.tsx | 27 +- .../main-app/ChartHeader/ChartHeader.spec.tsx | 49 +- .../ChartHeader/ChartHeader.styles.ts | 30 +- .../main-app/ChartHeader/ChartHeader.tsx | 98 +++- .../DateRangeHelpers/DateRangeHelpers.spec.ts | 26 + .../DateRangeHelpers/DateRangeHelpers.ts | 28 +- .../contentTypeRules/contentTypeRules.ts | 61 +++ .../hooks/useSidebarRules/useSidebarRules.tsx | 139 +++++ .../useSidebarSlug/useSidebarSlug.spec.tsx | 31 ++ .../hooks/useSidebarSlug/useSidebarSlug.tsx | 8 +- .../frontend/src/locations/Dialog.spec.tsx | 17 +- .../frontend/src/locations/Dialog.tsx | 86 +++- .../frontend/src/locations/Sidebar.tsx | 38 +- apps/google-analytics-4/frontend/src/types.ts | 38 +- .../frontend/src/utils/contentTypeMatching.ts | 9 + .../frontend/src/utils/getReportSlug.spec.ts | 49 ++ .../frontend/src/utils/getReportSlug.ts | 48 ++ .../frontend/test/mocks/mockSdk.ts | 5 + .../lambda/src/controllers/apiController.ts | 13 +- .../lambda/src/services/googleApi.spec.ts | 38 +- .../lambda/src/services/googleApiService.ts | 12 +- apps/google-analytics-4/lambda/src/types.ts | 5 + 42 files changed, 2125 insertions(+), 425 deletions(-) create mode 100644 apps/google-analytics-4/VALIDATION.md create mode 100644 apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts create mode 100644 apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx create mode 100644 apps/google-analytics-4/frontend/src/utils/contentTypeMatching.ts create mode 100644 apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts create mode 100644 apps/google-analytics-4/frontend/src/utils/getReportSlug.ts diff --git a/apps/google-analytics-4/README.md b/apps/google-analytics-4/README.md index 575365c272..49a283d8da 100644 --- a/apps/google-analytics-4/README.md +++ b/apps/google-analytics-4/README.md @@ -14,6 +14,8 @@ See the [Google Analytics 4 App Marketplace listing](https://contentful.com/mark - [Google Analytics 4 App Guide](https://www.contentful.com/help/google-analytics-4-app/) - [Installing a service account to use with Google Analytics 4](https://www.contentful.com/help/google-analytics-service-account-setup/) +For reviewer-specific setup and validation steps for this branch, see [VALIDATION.md](./VALIDATION.md). + ## Technical Overview ### General diff --git a/apps/google-analytics-4/VALIDATION.md b/apps/google-analytics-4/VALIDATION.md new file mode 100644 index 0000000000..ffe84e08f7 --- /dev/null +++ b/apps/google-analytics-4/VALIDATION.md @@ -0,0 +1,479 @@ +# Google Analytics 4 Reviewer Validation Guide + +This document explains how to validate the Google Analytics 4 app changes in a repeatable way. + +## What This PR Adds + +This branch adds support for: + +- advanced matching +- matching against `page path + query string` +- `Flexible pattern` matching +- multiple rules per content type +- multiple page properties per rule +- expanded preset date ranges +- custom date range selection +- `Unique views` +- clearer footer behavior when multiple rules contribute to one sidebar result + +## Recommended Validation Path + +The easiest path for reviewers is: + +1. Use the hosted staging app in a shared Contentful space. +2. Configure the app against a GA4 property that already has test traffic. +3. Open seeded entries and verify the sidebar output. + +## Hosted App + +Hosted staging app definition: + +- `Google Analytics 4 (staging)` +- app definition id: `42yub8HtSmIclUlVBHpHHP` + +Suggested validation spaces: + +- `u59a656msupb` +- `c661d7sie16h` + +If you are validating in a different space, use the content model and entry setup described below. + +## GA4 Setup + +Shared GA4 property used during development: + +- property name: `Contentful GA4 Test` +- property id: `properties/531110338` +- measurement id: `G-8YHK83TB7T` + +Shared service account metadata: + +- service account email: `contentful-ga4-reader@contentful-ga4-test.iam.gserviceaccount.com` +- Google Cloud project: `contentful-ga4-test` + +Do not commit the raw service-account JSON into the repo or PR. + +For validation, use one of these options: + +- use your own GA4 service-account JSON that has `Viewer` access to the same property +- get the shared JSON through a secure channel and paste it into the app config locally + +The JSON used during development is intentionally not stored in this repository because it contains a live private key. + +## Test Website + +The validation site used during development is a small static site under: + +- `/Users/zachary.yankiver/Documents/Marketplace Apps/ga4-test-site` + +If you have that sibling directory locally, you can run it with: + +```bash +cd /Users/zachary.yankiver/Documents/Marketplace\ Apps/ga4-test-site +node scripts/serve-static.mjs +``` + +Expected local URL: + +- `http://localhost:4173` + +If you do not have that sibling directory, you can recreate the same website by making a simple static site with the routes listed below and adding your GA4 Measurement ID to each page. + +## Test Routes + +These are the routes used during development: + +- `/` +- `/en-us/home/` +- `/de-de/home/` +- `/blog/my-post/` +- `/solutions/platform/` +- `/search/?category=platform` +- `/article/?articleId=360054483454` +- `/article/?articleId=360054483455` +- `/category/my-post/` +- `/interviews/my-post/` +- `/news/my-post/` +- `/guides/market-insights/` +- `/north-america/denver/luxury-homes/` + +Suggested full local URLs to hit before validating the app: + +- `http://localhost:4173/en-us/home/` +- `http://localhost:4173/de-de/home/` +- `http://localhost:4173/blog/my-post/` +- `http://localhost:4173/solutions/platform/` +- `http://localhost:4173/search/?category=platform` +- `http://localhost:4173/article/?articleId=360054483454` +- `http://localhost:4173/category/my-post/` +- `http://localhost:4173/interviews/my-post/` +- `http://localhost:4173/news/my-post/` +- `http://localhost:4173/guides/market-insights/` +- `http://localhost:4173/north-america/denver/luxury-homes/` + +Notes: + +- GA4 Realtime usually updates first. +- Standard GA4 reports and the app sidebar can lag behind Realtime. +- The `article` route is useful for exact query-string matching. +- The `category`, `interviews`, and `news` routes are useful for multiple-rule and `Flexible Post` validation. +- The `guides` and `north-america/denver` routes are useful for multiple-page-property validation. + +## Seeding Contentful Fixtures + +If you want to reproduce the same content model and entries in your own space, use the helper scripts from the test-site folder. + +### Standard Fixtures + +```bash +cd /Users/zachary.yankiver/Documents/Marketplace\ Apps/ga4-test-site +node scripts/seed-contentful-ga4-spaces.mjs +``` + +This creates: + +- `Page EN` +- `Page DE` +- `Blog Post` +- `Solution Page` +- `Search Page` + +And entries: + +- `English Home` +- `German Home` +- `Example Blog Article` +- `Platform Solution` +- `Search Results` + +### Flexible Pattern Fixture + +```bash +cd /Users/zachary.yankiver/Documents/Marketplace\ Apps/ga4-test-site +node scripts/seed-flexible-post-contentful-space.mjs +``` + +This creates: + +- `Flexible Post` + +And entry: + +- `Flexible Pattern Demo` + +### Multi-Field Fixtures + +```bash +cd /Users/zachary.yankiver/Documents/Marketplace\ Apps/ga4-test-site +node scripts/seed-multifield-contentful-ga4-space.mjs +``` + +This creates: + +- `Composed Article` +- `Regional Listing` + +And entries: + +- `Guides Market Insights` +- `Denver Luxury Homes` + +## Manual Content Model Setup + +If you prefer to create the fixtures by hand instead of running the scripts, create these content types and entries. + +### Standard Content Types + +`Page EN` + +- fields: + - `Title` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `English Home` + - slug: `home` + +`Page DE` + +- fields: + - `Title` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `German Home` + - slug: `home` + +`Blog Post` + +- fields: + - `Title` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `Example Blog Article` + - slug: `my-post` + +`Solution Page` + +- fields: + - `Title` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `Platform Solution` + - slug: `platform` + +`Search Page` + +- fields: + - `Title` + - `Slug` + - `Category` +- display field: + - `Title` +- example entry: + - title: `Search Results` + - slug: `platform` + - category: `platform` + +### Flexible Pattern Content Type + +`Flexible Post` + +- fields: + - `Title` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `Flexible Pattern Demo` + - slug: `my-post` + +### Multi-Field Content Types + +`Composed Article` + +- fields: + - `Title` + - `Section slug` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `Guides Market Insights` + - section slug: `guides` + - slug: `market-insights` + +`Regional Listing` + +- fields: + - `Title` + - `Region slug` + - `City slug` + - `Slug` +- display field: + - `Title` +- example entry: + - title: `Denver Luxury Homes` + - region slug: `north-america` + - city slug: `denver` + - slug: `luxury-homes` + +## App Configuration + +### API Access + +1. Paste a GA4 service-account JSON that has access to the shared property. +2. Select: + - `Contentful GA4 Test (531110338)` +3. Save the config before validating the content-type rules. + +### Global Setting + +- enable `Use trailing slash for all page paths` + +### Standard Rules + +`Page EN` + +- standard mode +- slug field: `Slug` +- URL prefix: `/en-us/` + +`Page DE` + +- standard mode +- slug field: `Slug` +- URL prefix: `/de-de/` + +`Blog Post` + +- standard mode +- slug field: `Slug` +- URL prefix: `/blog/` + +`Solution Page` + +- standard mode +- slug field: `Slug` +- URL prefix: `/solutions/` + +### Query String Rule + +`Search Page` + +- advanced mode: on +- slug field: `Slug` +- pattern: `/search/?category={slug}` +- match against: `Page path + query string` +- matching mode: `Literal` + +### Multiple Rules Per Content Type + +Create three separate rules for `Flexible Post`: + +Rule 1 + +- advanced mode: on +- slug field: `Slug` +- pattern: `/category/{slug}/` +- match against: `Page path` +- matching mode: `Literal` + +Rule 2 + +- advanced mode: on +- slug field: `Slug` +- pattern: `/interviews/{slug}/` +- match against: `Page path` +- matching mode: `Literal` + +Rule 3 + +- advanced mode: on +- slug field: `Slug` +- pattern: `/news/{slug}/` +- match against: `Page path` +- matching mode: `Literal` + +Note: Do not use a single flexible-pattern rule if you want to validate the new multi-rule footer summary. The multi-rule footer only appears when the same content type is configured as multiple separate rules. + +### Multiple Page Properties Per Rule + +`Composed Article` + +- advanced mode: on +- slug field: `Slug` +- additional page properties: `Section slug` +- pattern: `/{sectionSlug}/{slug}` +- match against: `Page path` +- matching mode: `Literal` + +`Regional Listing` + +- advanced mode: on +- slug field: `Slug` +- additional page properties: + - `Region slug` + - `City slug` +- pattern: `/{regionSlug}/{citySlug}/{slug}` +- match against: `Page path` +- matching mode: `Literal` + +## Expected Sidebar Results + +`English Home` + +- resolved path: `/en-us/home/` + +`German Home` + +- resolved path: `/de-de/home/` + +`Example Blog Article` + +- resolved path: `/blog/my-post/` + +`Platform Solution` + +- resolved path: `/solutions/platform/` + +`Search Results` + +- resolved path: `/search/?category=platform` + +`Flexible Pattern Demo` + +- should aggregate three rules +- footer should show: + - `Included paths (3)` + - `/category/my-post/` + - `/interviews/my-post/` + - `/news/my-post/` + +`Guides Market Insights` + +- resolved path: `/guides/market-insights/` + +`Denver Luxury Homes` + +- resolved path: `/north-america/denver/luxury-homes/` + +## Metrics To Validate + +Validate both metrics: + +- `Total views` +- `Unique views` + +Also validate: + +- preset ranges: + - `Last 24 hours` + - `Last 7 days` + - `Last 28 days` + - `Last 90 days` + - `Last 12 months` +- `Custom range` + +## Optional Local App Development Setup + +If you want to run the app locally instead of validating only through the hosted staging app: + +Frontend: + +```bash +cd /Users/zachary.yankiver/Documents/Marketplace\ Apps/apps/apps/google-analytics-4/frontend +HOST=localhost npm start +``` + +Backend: + +```bash +cd /Users/zachary.yankiver/Documents/Marketplace\ Apps/apps/apps/google-analytics-4/lambda +docker compose up --build +``` + +Expected local URLs: + +- app frontend: `http://localhost:3000` +- backend: `http://localhost:8080/dev` +- static test site: `http://localhost:4173` + +Note: If you use a local app definition, make sure its request-verification secret matches the local backend configuration. + +## Reviewer Checklist + +- Can the app save API access and property configuration successfully? +- Do standard URL-prefix rules still work? +- Does query-string matching work? +- Does one content type support multiple rules? +- Does one rule support multiple page properties? +- Do `Total views` and `Unique views` both render correctly? +- Do preset ranges and custom ranges work without layout glitches? +- Does the footer summarize multi-rule entries clearly? diff --git a/apps/google-analytics-4/frontend/src/apis/api.spec.ts b/apps/google-analytics-4/frontend/src/apis/api.spec.ts index 005b110565..5ad89a178a 100644 --- a/apps/google-analytics-4/frontend/src/apis/api.spec.ts +++ b/apps/google-analytics-4/frontend/src/apis/api.spec.ts @@ -193,5 +193,23 @@ describe('Api', () => { const result = await api.runReports(); expect(result).toEqual(runReportData); }); + + it('does not append undefined advanced matching params for standard requests', () => { + const api = new Api(contentfulContext, mockCma, validServiceKeyId); + + const url = api.appendQueryParams(new URL('http://example.com/api/run_report'), { + propertyId: 'properties/531110338', + slug: '/en-us/home/', + matchDimension: undefined, + matchType: undefined, + startDate: '2026-04-01', + endDate: '2026-04-07', + }); + + expect(url.searchParams.get('propertyId')).toBe('properties/531110338'); + expect(url.searchParams.get('slug')).toBe('/en-us/home/'); + expect(url.searchParams.get('matchDimension')).toBeNull(); + expect(url.searchParams.get('matchType')).toBeNull(); + }); }); }); diff --git a/apps/google-analytics-4/frontend/src/apis/api.ts b/apps/google-analytics-4/frontend/src/apis/api.ts index c9a0d97515..bccf41b98a 100644 --- a/apps/google-analytics-4/frontend/src/apis/api.ts +++ b/apps/google-analytics-4/frontend/src/apis/api.ts @@ -66,7 +66,10 @@ export class Api { encodeParam = (param: any) => encodeURIComponent(param); appendQueryParams = (url: URL, queryParams: any) => { - Object.keys(queryParams).forEach((key) => url.searchParams.append(key, queryParams[key])); + Object.entries(queryParams).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') return; + url.searchParams.append(key, String(value)); + }); return url; }; diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx index 886dc07362..4d4fd2fbea 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx @@ -18,6 +18,7 @@ import { config } from 'config'; import { convertServiceAccountKeyToServiceAccountKeyId } from 'utils/serviceAccountKey'; import HyperLink from 'components/common/HyperLink/HyperLink'; import { ExternalLinkIcon } from '@contentful/f36-icons'; +import { getUniqueContentTypeIds, normalizeContentTypeRules } from 'helpers/contentTypeRules/contentTypeRules'; export default function GoogleAnalyticsConfigPage() { const [accountsSummaries, setAccountsSummaries] = useState([]); @@ -137,18 +138,18 @@ export default function GoogleAnalyticsConfigPage() { let parametersToSave = parameters; - // Filter out empty content types that came from empty rows on the form - if (parameters.contentTypes) { - const nonEmptyContentTypes = Object.fromEntries( - Object.entries(parameters.contentTypes).filter(([key]) => key !== '') - ); + if (parameters.contentTypeRules || parameters.contentTypes) { + const nonEmptyContentTypeRules = normalizeContentTypeRules( + parameters.contentTypeRules, + parameters.contentTypes + ).filter((rule) => rule.contentTypeId !== ''); - parametersToSave = { ...parameters, contentTypes: nonEmptyContentTypes }; + parametersToSave = { ...parameters, contentTypeRules: nonEmptyContentTypeRules }; setParameters(parametersToSave); } // Assign the app to the sidebar for saved content types - const contentTypeIds = Object.keys(parametersToSave.contentTypes ?? {}); + const contentTypeIds = getUniqueContentTypeIds(parametersToSave.contentTypeRules ?? []); const newEditorInterfaceAssignments = generateEditorInterfaceAssignments( currentEditorInterface, contentTypeIds, @@ -285,6 +286,10 @@ export default function GoogleAnalyticsConfigPage() { parameters={parameters} currentEditorInterface={currentEditorInterface} originalContentTypes={originalParameters.contentTypes ?? {}} + originalContentTypeRules={normalizeContentTypeRules( + originalParameters.contentTypeRules, + originalParameters.contentTypes + )} /> )} diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts index 452230aabf..7f7093db37 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts @@ -1,13 +1,93 @@ import { css } from 'emotion'; export const styles = { + actionItem: css({ + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-start', + minHeight: '40px', + whiteSpace: 'nowrap', + }), + advancedMatchingFields: css({ + alignItems: 'flex-start', + display: 'flex', + gap: '12px', + flexWrap: 'wrap', + width: '100%', + }), + advancedMatchingTopRow: css({ + alignItems: 'flex-start', + display: 'flex', + gap: '12px', + width: '100%', + }), + advancedMatchingBottomRow: css({ + alignItems: 'flex-start', + display: 'flex', + gap: '12px', + marginTop: '8px', + width: '100%', + }), contentTypeItem: css({ flex: 4, }), + advancedMatchingPanel: css({ + background: '#FCFDFD', + border: '1px solid #E5EBED', + borderRadius: '6px', + marginTop: '4px', + padding: '10px 12px 12px', + width: '100%', + }), + advancedMatchingIntro: css({ + display: 'block', + marginBottom: '12px', + }), + baseRow: css({ + alignItems: 'center', + display: 'flex', + gap: '12px', + width: '100%', + }), removeItem: css({ - flex: 1, + flex: 1.2, + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-start', + minHeight: '40px', }), statusItem: css({ flex: 0.5, }), + stackedField: css({ + flex: 1, + minWidth: '220px', + }), + compactField: css({ + flex: 1, + minWidth: '220px', + }), + tooltipIcon: css({ + alignItems: 'center', + color: '#111B2B', + display: 'inline-flex', + lineHeight: 0, + '& svg': { + color: '#111B2B !important', + fill: '#111B2B !important', + height: '18px', + width: '18px', + }, + }), + toggleItem: css({ + alignItems: 'center', + display: 'flex', + flex: 0.9, + justifyContent: 'flex-start', + minHeight: '40px', + minWidth: '150px', + }), + urlPrefixItem: css({ + flex: 3, + }), }; diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx index b73097f302..980c54b7ec 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx @@ -2,8 +2,7 @@ import { render, screen } from '@testing-library/react'; import { AllContentTypes, AllContentTypeEntries, - ContentTypes, - ContentTypeEntries, + ContentTypeRules, } from '../../../types'; import AssignContentTypeCard from 'components/config-screen/assign-content-type/AssignContentTypeCard'; @@ -22,14 +21,17 @@ const allContentTypes: AllContentTypes = { const allContentTypeEntries: AllContentTypeEntries = Object.entries(allContentTypes); -const contentTypes: ContentTypes = { - course: { +const contentTypeRules: ContentTypeRules = [ + { + id: 'rule-course', + contentTypeId: 'course', slugField: 'slug', urlPrefix: '/about', + enableAdvancedMatching: false, + matchDimension: 'unifiedPagePathScreen', + matchType: 'EXACT', }, -}; - -const contentTypeEntries: ContentTypeEntries = Object.entries(contentTypes); +]; describe('Assign Content Type Card for Config Screen', () => { it('can render the field labels when there is a saved content type entry', () => { @@ -37,19 +39,19 @@ describe('Assign Content Type Card for Config Screen', () => { {}} onContentTypeFieldChange={() => {}} onRemoveContentType={() => {}} currentEditorInterface={{}} - originalContentTypes={{}} + originalContentTypeRules={[]} /> ); expect(screen.getByText('Content type')).toBeVisible(); expect(screen.getByText('Slug field')).toBeVisible(); expect(screen.getByText('URL prefix')).toBeVisible(); + expect(screen.queryByText('Path pattern')).not.toBeInTheDocument(); }); it('can render the correct number of saved content types', () => { @@ -57,13 +59,12 @@ describe('Assign Content Type Card for Config Screen', () => { {}} onContentTypeFieldChange={() => {}} onRemoveContentType={() => {}} currentEditorInterface={{}} - originalContentTypes={{}} + originalContentTypeRules={[]} /> ); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx index 5cd52821f6..31d1fa25b1 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx @@ -1,40 +1,42 @@ -import { Box, Card, Flex, FormControl, Stack, Tooltip } from '@contentful/f36-components'; +import { Box, Card, Flex, FormControl, Tooltip } from '@contentful/f36-components'; import { HelpCircleIcon } from '@contentful/f36-icons'; import { styles } from 'components/config-screen/assign-content-type/AssignContentType.styles'; import { EditorInterface } from '@contentful/app-sdk'; -import { AllContentTypes, AllContentTypeEntries, ContentTypes, ContentTypeEntries } from 'types'; +import { AllContentTypes, AllContentTypeEntries, ContentTypeRules } from 'types'; import AssignContentTypeRow from 'components/config-screen/assign-content-type/AssignContentTypeRow'; interface AssignContentTypeCardProps { allContentTypes: AllContentTypes; allContentTypeEntries: AllContentTypeEntries; - contentTypes: ContentTypes; - contentTypeEntries: ContentTypeEntries; - onContentTypeChange: (prevKey: string, newKey: string) => void; - onContentTypeFieldChange: (key: string, field: string, value: string) => void; - onRemoveContentType: (key: string) => void; + contentTypeRules: ContentTypeRules; + onContentTypeChange: (ruleId: string, newContentTypeId: string) => void; + onContentTypeFieldChange: (ruleId: string, field: string, value: string | boolean | string[]) => void; + onRemoveContentType: (ruleId: string) => void; currentEditorInterface: Partial; - originalContentTypes: ContentTypes; + originalContentTypeRules: ContentTypeRules; } interface HeaderLabelProps { label: string; helpText?: string; + className?: string; } const HeaderLabel = (props: HeaderLabelProps) => { - const { label, helpText } = props; + const { label, helpText, className } = props; + + const defaultClassName = label === 'URL prefix' ? styles.urlPrefixItem : styles.contentTypeItem; return ( - + {label} {helpText && ( - - + + )} @@ -49,19 +51,17 @@ const AssignContentTypeCard = (props: AssignContentTypeCardProps) => { const { allContentTypes, allContentTypeEntries, - contentTypes, - contentTypeEntries, + contentTypeRules, onContentTypeChange, onContentTypeFieldChange, onRemoveContentType, currentEditorInterface, - originalContentTypes, + originalContentTypeRules, } = props; return ( - - + { /> + - - {contentTypeEntries.map((contentTypeEntry, index) => { + + {contentTypeRules.map((contentTypeRule, index) => { return ( ); })} diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx index 5932903608..d1fdbef964 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx @@ -1,44 +1,47 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { AllContentTypes, AllContentTypeEntries, ContentTypes } from 'types'; +import { AllContentTypes, AllContentTypeEntries, ContentTypeRule, ContentTypeRules } from 'types'; import AssignContentTypeRow from 'components/config-screen/assign-content-type/AssignContentTypeRow'; import { vi } from 'vitest'; const allContentTypes: AllContentTypes = { course: { name: 'Course', - fields: [ - { - id: 'slug', - name: 'Slug', - type: 'Symbol', - }, - ], + fields: [{ id: 'slug', name: 'Slug', type: 'Symbol' }], }, category: { name: 'Category', fields: [ - { - id: 'title', - name: 'Title', - type: 'Symbol', - }, + { id: 'title', name: 'Title', type: 'Symbol' }, + { id: 'sectionSlug', name: 'Section slug', type: 'Symbol' }, ], }, }; const allContentTypeEntries: AllContentTypeEntries = Object.entries(allContentTypes); -const contentTypes: ContentTypes = { - course: { +const contentTypeRules: ContentTypeRules = [ + { + id: 'rule-course', + contentTypeId: 'course', slugField: 'slug', urlPrefix: '/about', + enableAdvancedMatching: false, + pathPattern: '', + matchDimension: 'unifiedPagePathScreen', + matchType: 'EXACT', }, - category: { + { + id: 'rule-category', + contentTypeId: 'category', slugField: 'title', urlPrefix: '', + enableAdvancedMatching: false, + pathPattern: '', + matchDimension: 'unifiedPagePathScreen', + matchType: 'EXACT', }, -}; +]; const onRemoveContentType = vi.fn(); const onContentTypeChange = vi.fn(); @@ -46,47 +49,59 @@ const onContentTypeFieldChange = vi.fn(); const props = { index: 0, - allContentTypes: allContentTypes, - allContentTypeEntries: allContentTypeEntries, - contentTypes: contentTypes, - onContentTypeChange: onContentTypeChange, - onContentTypeFieldChange: onContentTypeFieldChange, - onRemoveContentType: onRemoveContentType, + allContentTypes, + allContentTypeEntries, + contentTypeRules, + onContentTypeChange, + onContentTypeFieldChange, + onRemoveContentType, currentEditorInterface: {}, - originalContentTypes: {}, + originalContentTypeRules: [], focus: false, }; describe('Assign Content Type Card for Config Screen', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('shows disabled inputs when content type is empty', () => { render( ); expect(screen.getByTestId('slugFieldSelect')).toBeDisabled(); - expect(screen.getByTestId('urlPrefixInput')).toBeDisabled(); + expect(screen.getByRole('checkbox', { name: 'Advanced' })).toBeDisabled(); + expect(screen.queryByTestId('advancedMatchingPanel')).not.toBeInTheDocument(); + expect(screen.getByTestId('urlPrefixInput')).toBeVisible(); }); it('calls remove handler when remove link is clicked', async () => { const user = userEvent.setup(); render( ); @@ -96,82 +111,156 @@ describe('Assign Content Type Card for Config Screen', () => { expect(onRemoveContentType).toHaveBeenCalled(); }); - it('calls remove handler when remove link is clicked', async () => { + it('calls change handler when content type selection is changed', async () => { const user = userEvent.setup(); render( ); - await user.click(screen.getByText('Remove')); + await user.selectOptions(screen.getByTestId('contentTypeSelect'), ['course']); - expect(onRemoveContentType).toHaveBeenCalled(); + expect(onContentTypeChange).toHaveBeenCalled(); }); - it('calls change handler when content type selection is changed', async () => { + it('calls field change handler when slug field selection is changed', async () => { const user = userEvent.setup(); render( ); - await user.selectOptions(screen.getByTestId('contentTypeSelect'), ['course']); + await user.selectOptions(screen.getByTestId('slugFieldSelect'), ['slug']); - expect(onContentTypeChange).toHaveBeenCalled(); + expect(onContentTypeFieldChange).toHaveBeenCalled(); }); - it('calls field change handler when slug field selection is changed', async () => { + it('calls field change handler when url prefix input is changed', async () => { const user = userEvent.setup(); render( ); - await user.selectOptions(screen.getByTestId('slugFieldSelect'), ['slug']); + await user.type(screen.getByTestId('urlPrefixInput'), '/en-US'); expect(onContentTypeFieldChange).toHaveBeenCalled(); }); - it('calls field change handler when url prefix input is changed', async () => { + it('reveals advanced matching controls when advanced toggle is enabled', async () => { const user = userEvent.setup(); render( ); - await user.type(screen.getByTestId('urlPrefixInput'), '/en-US'); + await user.click(screen.getByTestId('advancedMatchingToggle')); + + expect(screen.getByTestId('advancedMatchingPanel')).toBeVisible(); + expect(onContentTypeFieldChange).toHaveBeenCalledWith('rule-course', 'enableAdvancedMatching', true); + }); + + it('shows advanced matching controls when a row is already configured for them', () => { + render( + + ); + + expect(screen.getByTestId('advancedMatchingPanel')).toBeVisible(); + expect(screen.getByTestId('pathPatternInput')).toHaveValue('/blog/{slug}'); + expect(screen.queryByTestId('urlPrefixInput')).not.toBeInTheDocument(); + }); + + it('shows a preview with additional page property tokens when configured', () => { + render( + + ); + + expect(screen.getByTestId('pathPatternInput')).toHaveValue('/{sectionSlug}/{slug}'); + }); + + it('calls field change handler when path pattern input is changed', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.type(screen.getByTestId('pathPatternInput'), '/blog/{slug}'); + + expect(onContentTypeFieldChange).toHaveBeenCalled(); + }); + + it('calls field change handler when match dimension selection is changed', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.selectOptions(screen.getByTestId('matchDimensionSelect'), [ + 'pagePathPlusQueryString', + ]); + + expect(onContentTypeFieldChange).toHaveBeenCalled(); + }); + + it('calls field change handler when matching mode selection is changed', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.selectOptions(screen.getByTestId('matchTypeSelect'), ['PARTIAL_REGEXP']); expect(onContentTypeFieldChange).toHaveBeenCalled(); }); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx index affa5514a9..253d56202a 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx @@ -1,142 +1,151 @@ import { useEffect, useState } from 'react'; -import { Box, Select, Stack, TextInput, TextLink } from '@contentful/f36-components'; +import { + Box, + Checkbox, + Flex, + FormControl, + Select, + Stack, + Text, + TextInput, + TextLink, + Tooltip, +} from '@contentful/f36-components'; +import { HelpCircleIcon } from '@contentful/f36-icons'; import { styles } from 'components/config-screen/assign-content-type/AssignContentType.styles'; import { EditorInterface } from '@contentful/app-sdk'; -import { AllContentTypes, AllContentTypeEntries, ContentTypes, ContentTypeValue } from 'types'; +import { + AllContentTypes, + AllContentTypeEntries, + ContentTypeRule, + ContentTypeRules, + ContentTypeValue, +} from 'types'; import ContentTypeWarning from 'components/config-screen/assign-content-type/ContentTypeWarning'; +import { pathPatternPreview } from 'utils/getReportSlug'; +import { hasAdvancedMatchingConfigured } from 'utils/contentTypeMatching'; interface Props { - contentTypeEntry: [string, ContentTypeValue]; + contentTypeRule: ContentTypeRule; index: number; allContentTypes: AllContentTypes; allContentTypeEntries: AllContentTypeEntries; - contentTypes: ContentTypes; - onContentTypeChange: (prevKey: string, newKey: string) => void; - onContentTypeFieldChange: (key: string, field: string, value: string) => void; - onRemoveContentType: (key: string) => void; + contentTypeRules: ContentTypeRules; + onContentTypeChange: (ruleId: string, newContentTypeId: string) => void; + onContentTypeFieldChange: (ruleId: string, field: string, value: string | boolean | string[]) => void; + onRemoveContentType: (ruleId: string) => void; currentEditorInterface: Partial; - originalContentTypes: ContentTypes; + originalContentTypeRules: ContentTypeRules; focus: boolean; } const AssignContentTypeRow = (props: Props) => { const { - contentTypeEntry, + contentTypeRule, index, allContentTypes, allContentTypeEntries, - contentTypes, + contentTypeRules, onContentTypeChange, onContentTypeFieldChange, onRemoveContentType, currentEditorInterface, - originalContentTypes, + originalContentTypeRules, focus, } = props; - const [contentTypeId, { slugField, urlPrefix }] = contentTypeEntry; + const [ + ruleId, + { + contentTypeId, + slugField, + urlPrefix, + enableAdvancedMatching, + pathPattern = '', + additionalFieldIds = [], + matchDimension = 'unifiedPagePathScreen', + matchType = 'EXACT', + }, + ] = [contentTypeRule.id, contentTypeRule]; - const [isSaved, setIsSaved] = useState(false); + const [isSaved, setIsSaved] = useState(false); const [contentTypeOptions, setContentTypeOptions] = useState([]); - const [isContentTypeInOptions, setIsContentTypeInOptions] = useState(true); - const [isSlugFieldInOptions, setIsSlugFieldInOptions] = useState(true); - const [isInSidebar, setIsInSidebar] = useState(false); + const [isContentTypeInOptions, setIsContentTypeInOptions] = useState(true); + const [isSlugFieldInOptions, setIsSlugFieldInOptions] = useState(true); + const [isInSidebar, setIsInSidebar] = useState(false); + const [showAdvancedMatching, setShowAdvancedMatching] = useState( + Boolean(enableAdvancedMatching || hasAdvancedMatchingConfigured(contentTypeRule)) + ); useEffect(() => { - const originalContentTypeIds = Object.keys(originalContentTypes); - if (originalContentTypeIds.includes(contentTypeId)) { - setIsSaved(true); - } else { - setIsSaved(false); - } - }, [contentTypeId, originalContentTypes]); + setIsSaved(originalContentTypeRules.some((rule) => rule.id === ruleId)); + }, [originalContentTypeRules, ruleId]); useEffect(() => { - const savedSidebarLocations = Object.keys(currentEditorInterface); - if (savedSidebarLocations.includes(contentTypeId)) { - setIsInSidebar(true); - } else { - setIsInSidebar(false); - } + setIsInSidebar(Object.keys(currentEditorInterface).includes(contentTypeId)); }, [contentTypeId, currentEditorInterface]); useEffect(() => { - const contentTypeOptions = allContentTypeEntries.filter( - ([type]) => type === contentTypeId || !contentTypes[type] - ); - setContentTypeOptions(contentTypeOptions); + const nextContentTypeOptions = allContentTypeEntries; + setContentTypeOptions(nextContentTypeOptions); + if (isSaved) { - setIsContentTypeInOptions(contentTypeOptions.some((option) => option[0] === contentTypeId)); + setIsContentTypeInOptions( + nextContentTypeOptions.some((option) => option[0] === contentTypeId) + ); if (slugField !== undefined) { setIsSlugFieldInOptions( allContentTypes[contentTypeId]?.fields.some((field) => field.id === slugField) ); } } - }, [allContentTypeEntries, contentTypeId, contentTypes, allContentTypes, isSaved, slugField]); + }, [allContentTypeEntries, allContentTypes, contentTypeId, contentTypeRules, isSaved, slugField]); + + useEffect(() => { + setShowAdvancedMatching( + Boolean(enableAdvancedMatching || hasAdvancedMatchingConfigured(contentTypeRule)) + ); + }, [contentTypeRule, enableAdvancedMatching]); + + useEffect(() => { + if (focus) { + const contentTypeSelect = document.getElementById(`contentType-${index}`); + if (contentTypeSelect) contentTypeSelect.focus(); + } + }, [focus, index]); - const validateSelectedOption = (contentTypeId: string, slugField?: string) => { + const validateSelectedOption = (entryContentTypeId: string, fieldId?: string) => { let value = ''; if ( - slugField === undefined && - contentTypeOptions.some((option) => option[0] === contentTypeId) + fieldId === undefined && + contentTypeOptions.some((option) => option[0] === entryContentTypeId) ) { - value = contentTypeId; + value = entryContentTypeId; } if ( - slugField !== undefined && - allContentTypes[contentTypeId]?.fields.some((field) => field.id === slugField) + fieldId !== undefined && + allContentTypes[entryContentTypeId]?.fields.some((field) => field.id === fieldId) ) { - value = slugField; + value = fieldId; } return value; }; - const ContentTypeOptions = () => { - return ( - <> - - Select content type - - {contentTypeOptions.map(([type, { name: typeName }]) => { - return ( - - {typeName} - - ); - })} - - ); - }; - - const SlugFieldOptions = () => { - return ( - <> - - Select slug field - - {contentTypeId && - allContentTypes[contentTypeId]?.fields?.map((field) => ( - - {field.name} - - ))} - - ); - }; - - useEffect(() => { - if (focus) { - const contentTypeSelect = document.getElementById(`contentType-${index}`); - if (contentTypeSelect) contentTypeSelect.focus(); - } - }, [focus, index]); + const selectableAdditionalFields = + contentTypeId && allContentTypes[contentTypeId]?.fields + ? allContentTypes[contentTypeId].fields.filter((field) => field.id !== slugField) + : []; return ( - + { isContentTypeInOptions={isContentTypeInOptions} isSlugFieldInOptions={isSlugFieldInOptions} /> - - - - - - - - ) => - onContentTypeFieldChange(contentTypeId, 'urlPrefix', event.target.value) - } - value={urlPrefix} - /> - - - onRemoveContentType(contentTypeId)}> - Remove - - + + + + + + + + + {!showAdvancedMatching ? ( + ) => + onContentTypeFieldChange(ruleId, 'urlPrefix', event.target.value) + } + value={urlPrefix} + /> + ) : ( + Configured below + )} + + + ) => { + const nextIsAdvanced = event.target.checked; + setShowAdvancedMatching(nextIsAdvanced); + onContentTypeFieldChange(ruleId, 'enableAdvancedMatching', nextIsAdvanced); + }}> + Advanced + + + + onRemoveContentType(ruleId)}> + Remove + + + + {showAdvancedMatching && ( + + + Use this for query strings or variable prefixes. + + + + + + Additional page properties + + {selectableAdditionalFields.length ? ( + selectableAdditionalFields.map((field) => ( + ) => { + const checked = event.target.checked; + const nextSelectedFields = checked + ? [...additionalFieldIds, field.id] + : additionalFieldIds.filter((selectedFieldId) => selectedFieldId !== field.id); + + onContentTypeFieldChange(ruleId, 'additionalFieldIds', nextSelectedFields); + }}> + {field.name} + + )) + ) : ( + No extra fields available for this content type. + )} + + + Select any extra fields you want to reference in the pattern. + + + + + + Pattern + ) => + onContentTypeFieldChange(ruleId, 'pathPattern', event.target.value) + } + value={pathPattern} + /> + + Use {'{slug}'} plus any selected property tokens. Example: /{'{sectionSlug}'}/{'{slug}'} + + + + + + + + Match against + + + + + + + + Matching mode + + + + + + + + + + + + + + )} ); }; diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx index fbc011457a..971de09bdc 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx @@ -20,6 +20,7 @@ describe('Assign Content Type Section for Config Screen', () => { parameters={{}} currentEditorInterface={{}} originalContentTypes={{}} + originalContentTypeRules={[]} /> ); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx index 194944d58a..25233c31e0 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx @@ -18,18 +18,24 @@ import { useSDK } from '@contentful/react-apps-toolkit'; import { AllContentTypes, AllContentTypeEntries, - ContentTypeEntries, + ContentTypeRule, + ContentTypeRules, ContentTypes, - ContentTypeValue, } from 'types'; import AssignContentTypeCard from 'components/config-screen/assign-content-type/AssignContentTypeCard'; import { sortAndFormatAllContentTypes } from 'helpers/contentTypeHelpers/contentTypeHelpers'; +import { + createDefaultRule, + getUniqueContentTypeIds, + normalizeContentTypeRules, +} from 'helpers/contentTypeRules/contentTypeRules'; interface Props { mergeSdkParameters: Function; onIsValidContentTypeAssignment: Function; parameters: KeyValueMap; currentEditorInterface: Partial; originalContentTypes: ContentTypes; + originalContentTypeRules: ContentTypeRules; } const AssignContentTypeSection = (props: Props) => { @@ -39,17 +45,15 @@ const AssignContentTypeSection = (props: Props) => { parameters, currentEditorInterface, originalContentTypes, + originalContentTypeRules, } = props; const [forceTrailingSlash, setForceTrailingSlash] = useState(false); - // Content type state - const [contentTypes, setContentTypes] = useState({} as ContentTypes); + // Content type rules state + const [contentTypeRules, setContentTypeRules] = useState([] as ContentTypeRules); const [loadingContentTypes, setLoadingContentTypes] = useState(true); const [hasContentTypes, setHasContentTypes] = useState(false); - const [contentTypeEntries, setContentTypeEntries] = useState( - [] as ContentTypeEntries - ); const [hasIncompleteContentTypes, setHasIncompleteContentTypes] = useState(false); // All content type state @@ -64,17 +68,21 @@ const AssignContentTypeSection = (props: Props) => { useEffect(() => { setLoadingContentTypes(true); if (parameters.forceTrailingSlash) setForceTrailingSlash(parameters.forceTrailingSlash); - if (parameters.contentTypes) setContentTypes(parameters.contentTypes); + setContentTypeRules( + normalizeContentTypeRules( + parameters.contentTypeRules as ContentTypeRules | undefined, + parameters.contentTypes as ContentTypes | undefined + ) + ); setLoadingContentTypes(false); - }, [parameters.contentTypes, parameters.forceTrailingSlash]); + }, [parameters.contentTypeRules, parameters.contentTypes, parameters.forceTrailingSlash]); useEffect(() => { - setHasContentTypes(Object.keys(contentTypes).length ? true : false); - setContentTypeEntries(Object.entries(contentTypes)); + setHasContentTypes(contentTypeRules.length > 0); setHasIncompleteContentTypes( - Object.entries(contentTypes).some(([contentTypeId]) => !contentTypeId) + contentTypeRules.some((rule) => !rule.contentTypeId) ); - }, [contentTypes]); + }, [contentTypeRules]); const fetchAllContentTypes = async (sdk: KnownAppSDK): Promise => { const cma = createClient({ apiAdapter: sdk.cmaAdapter }); @@ -119,58 +127,52 @@ const AssignContentTypeSection = (props: Props) => { onIsValidContentTypeAssignment(true); }; - const contentTypeHandler = (newContentTypes: ContentTypes) => { - setContentTypes(newContentTypes); - const _parameters = { contentTypes: newContentTypes }; + const contentTypeRulesHandler = (newContentTypeRules: ContentTypeRules) => { + setContentTypeRules(newContentTypeRules); + const _parameters = { contentTypeRules: newContentTypeRules }; mergeSdkParameters(_parameters); // We always want the user to be able to save the configuration, even if there are errors or warnings onIsValidContentTypeAssignment(true); }; - const handleContentTypeChange = (prevKey: string, newKey: string) => { - const newContentTypes: ContentTypes = {}; - - for (const [prop, value] of Object.entries(contentTypes)) { - if (prop === prevKey) { - newContentTypes[newKey as keyof typeof contentTypes] = { - slugField: '', - urlPrefix: value.urlPrefix, - }; - } else { - newContentTypes[prop] = value; - } - } + const handleContentTypeChange = (ruleId: string, newContentTypeId: string) => { + const newContentTypeRules = contentTypeRules.map((rule) => + rule.id === ruleId + ? { + ...rule, + contentTypeId: newContentTypeId, + slugField: '', + additionalFieldIds: [], + } + : rule + ); - contentTypeHandler(newContentTypes); + contentTypeRulesHandler(newContentTypeRules); }; - const handleContentTypeFieldChange = (key: string, field: string, value: string) => { - const currentContentTypeFields: ContentTypeValue = contentTypes[key]; - const newContentTypes: ContentTypes = { - ...contentTypes, - [key]: { - ...currentContentTypeFields, - [field]: value, - }, - }; + const handleContentTypeFieldChange = ( + ruleId: string, + field: string, + value: string | boolean | string[] + ) => { + const newContentTypeRules = contentTypeRules.map((rule) => + rule.id === ruleId + ? { + ...rule, + [field]: value, + } + : rule + ); - contentTypeHandler(newContentTypes); + contentTypeRulesHandler(newContentTypeRules); }; const handleAddContentType = () => { - const newContentTypes: ContentTypes = { - ...contentTypes, - '': { slugField: '', urlPrefix: '' }, - }; - - contentTypeHandler(newContentTypes); + contentTypeRulesHandler([...contentTypeRules, createDefaultRule()]); }; - const handleRemoveContentType = (key: string) => { - const newContentTypes = { ...contentTypes }; - delete newContentTypes[key]; - - contentTypeHandler(newContentTypes); + const handleRemoveContentType = (ruleId: string) => { + contentTypeRulesHandler(contentTypeRules.filter((rule) => rule.id !== ruleId)); }; return ( @@ -208,18 +210,17 @@ const AssignContentTypeSection = (props: Props) => { )} - {Object.keys(contentTypes).length < Object.keys(allContentTypes).length && ( + {contentTypeRules.length < Object.keys(allContentTypes).length * 5 && ( )} diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx index 7cb5e3ed81..9f90757ec4 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx @@ -8,7 +8,7 @@ import { EMPTY_DATA_MSG, getContentTypeSpecificMsg, } from 'components/main-app/constants/noteMessages'; -import * as useSidebarSlug from 'hooks/useSidebarSlug/useSidebarSlug'; +import * as useSidebarRules from 'hooks/useSidebarRules/useSidebarRules'; import { vi } from 'vitest'; vi.mock('@contentful/react-apps-toolkit', () => ({ @@ -30,7 +30,8 @@ const renderAnalyticsApp = async () => ); @@ -40,13 +41,20 @@ describe('AnalyticsApp with correct content types configured', () => { serviceAccountKeyId: validServiceKeyId, }); - vi.spyOn(useSidebarSlug, 'useSidebarSlug').mockImplementation(() => ({ - slugFieldIsConfigured: true, - contentTypeHasSlugField: true, - isPublished: true, - reportSlug: 'report slug', - slugFieldValue: '', + vi.spyOn(useSidebarRules, 'useSidebarRules').mockImplementation(() => ({ + validRules: [ + { + id: 'rule-title', + contentTypeId: 'category', + slugField: 'title', + urlPrefix: '', + reportSlug: 'report slug', + enableAdvancedMatching: false, + }, + ], + summaryLabel: 'report slug', isContentTypeWarning: false, + warningRule: undefined, })); }); @@ -98,13 +106,11 @@ describe('AnalyticsApp with correct content types configured', () => { describe('AnalyticsApp when content types are not configured correctly', () => { it('renders SlugWarningDisplay component when slug field is not configured', async () => { - vi.spyOn(useSidebarSlug, 'useSidebarSlug').mockImplementation(() => ({ - slugFieldIsConfigured: true, - contentTypeHasSlugField: false, - isPublished: true, - reportSlug: '', - slugFieldValue: '', + vi.spyOn(useSidebarRules, 'useSidebarRules').mockImplementation(() => ({ + validRules: [], + summaryLabel: '', isContentTypeWarning: true, + warningRule: { id: 'rule-title', contentTypeId: 'category', slugField: 'slug', urlPrefix: '' }, })); mockApi.mockImplementation(() => runReportResponseHasViews); const warningMessage = getContentTypeSpecificMsg('Category') diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx index 6a01147338..a25133bd10 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx @@ -1,74 +1,137 @@ import { useAutoResizer } from '@contentful/react-apps-toolkit'; -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState } from 'react'; import { Api } from 'apis/api'; import getRangeDates from 'helpers/DateRangeHelpers/DateRangeHelpers'; -import { DateRangeType, StartEndDates, ContentTypeValue } from 'types'; +import { + AnalyticsMetricType, + DateRangeType, + StartEndDates, + ContentTypeRule, + RunReportResponse, + CustomRangeDialogResult, +} from 'types'; import { Skeleton } from '@contentful/f36-components'; import { isEmpty } from 'lodash'; import { RunReportData } from 'apis/apiTypes'; -import { useSidebarSlug } from 'hooks/useSidebarSlug/useSidebarSlug'; +import { useSidebarRules } from 'hooks/useSidebarRules/useSidebarRules'; import SlugWarningDisplay from 'components/main-app/SlugWarningDisplay/SlugWarningDisplay'; import AnalyticsMetricDisplay from 'components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay'; interface Props { api: Api; propertyId: string; - slugFieldInfo: ContentTypeValue; + slugFieldRules: ContentTypeRule[]; + openCustomRangeDialog: (startEndDates: StartEndDates) => Promise; } + +const mergeRunReportResponses = (responses: RunReportData[]): RunReportData => { + const baseResponse = responses[0]; + const rowMap = new Map(); + + responses.forEach((response) => { + response.rows.forEach((row) => { + const dateKey = row.dimensionValues[0].value; + const metricValue = Number(row.metricValues[0].value || 0); + rowMap.set(dateKey, (rowMap.get(dateKey) || 0) + metricValue); + }); + }); + + const rows = Array.from(rowMap.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([dateKey, value]) => ({ + dimensionValues: [{ value: dateKey, oneValue: 'value' }], + metricValues: [{ value: String(value), oneValue: 'value' }], + })); + + return { + ...baseResponse, + rows, + rowCount: rows.length, + } as RunReportResponse; +}; + const AnalyticsApp = (props: Props) => { - const { api, propertyId, slugFieldInfo } = props; + const { api, propertyId, slugFieldRules, openCustomRangeDialog } = props; const [runReportResponse, setRunReportResponse] = useState({} as RunReportData); const [dateRange, setDateRange] = useState('lastWeek'); + const [selectedMetric, setSelectedMetric] = useState('screenPageViews'); const [startEndDates, setStartEndDates] = useState(getRangeDates('lastWeek')); const [loading, setLoading] = useState(true); const [error, setError] = useState(); - const { reportSlug, isContentTypeWarning } = useSidebarSlug(slugFieldInfo); + const { validRules, summaryLabel, isContentTypeWarning, warningRule, haveLoadedFieldValues } = + useSidebarRules(slugFieldRules); useAutoResizer(); - const reportRequestParams = useMemo( - () => ({ - startDate: startEndDates.start, - endDate: startEndDates.end, - propertyId, - dimensions: ['date'], - metrics: ['screenPageViews'], - slug: reportSlug, - }), - [startEndDates.start, startEndDates.end, reportSlug, propertyId] - ); - - const runReportFetchRequirements = reportSlug && propertyId && !isContentTypeWarning; + const runReportFetchRequirements = + haveLoadedFieldValues && validRules.length > 0 && propertyId && !isContentTypeWarning; useEffect(() => { async function fetchRunReportData() { + setLoading(true); + setError(undefined); + try { - const reportData = await api.runReports(reportRequestParams); - setRunReportResponse(reportData); + const reportData = await Promise.all( + validRules.map((rule) => + api.runReports({ + startDate: startEndDates.start, + endDate: startEndDates.end, + propertyId, + dimensions: ['date'], + metrics: [selectedMetric], + slug: rule.reportSlug, + matchDimension: rule.enableAdvancedMatching ? rule.matchDimension : undefined, + matchType: rule.enableAdvancedMatching ? rule.matchType : undefined, + }) + ) + ); + + setRunReportResponse(mergeRunReportResponses(reportData)); setError(undefined); } catch (e) { setError(e as Error); + } finally { + setLoading(false); } } - if (runReportFetchRequirements) fetchRunReportData(); - - setLoading(false); - }, [api, reportRequestParams, runReportFetchRequirements]); - - useEffect(() => { - if (runReportResponse.rowCount) { - setRunReportResponse(runReportResponse); + if (runReportFetchRequirements) { + fetchRunReportData(); + } else { + setError(undefined); + setLoading(false); + } + }, [ + api, + propertyId, + runReportFetchRequirements, + selectedMetric, + startEndDates.end, + startEndDates.start, + validRules, + ]); + + const handleDateRangeChange = async (e: DateRangeType) => { + if (e === 'custom') { + const customRange = await openCustomRangeDialog(startEndDates); + if (customRange) { + setDateRange('custom'); + setStartEndDates(customRange); + } + return; } - }, [dateRange, runReportResponse]); - const handleDateRangeChange = (e: DateRangeType) => { setDateRange(e); setStartEndDates(getRangeDates(e)); }; + const handleMetricChange = (metric: AnalyticsMetricType) => { + setSelectedMetric(metric); + }; + const pageViews = runReportResponse.rows && runReportResponse.rows.reduce((acc, val) => { @@ -79,9 +142,10 @@ const AnalyticsApp = (props: Props) => { const metricName = runReportResponse.metricHeaders && runReportResponse.metricHeaders[0].name; const pendingData = isEmpty(runReportResponse) && !error && runReportFetchRequirements; + const showInitialLoadingSkeleton = (loading && pendingData) || !haveLoadedFieldValues; const renderAnalyticContent = () => { - if (loading || pendingData) { + if (showInitialLoadingSkeleton) { return ( @@ -91,21 +155,27 @@ const AnalyticsApp = (props: Props) => { ); } - if (isContentTypeWarning) { - return ; + if (isContentTypeWarning && haveLoadedFieldValues) { + return ; } return ( - handleDateRangeChange('custom')} + runReportResponse={runReportResponse} + metricName={metricName} + reportSlug={summaryLabel} + includedPaths={validRules.map((rule) => rule.reportSlug)} + canOpenInGoogleAnalytics={validRules.length === 1} pageViews={pageViews} error={error} propertyId={propertyId} startEndDates={startEndDates} selectedDateRange={dateRange} + selectedMetric={selectedMetric} + isLoading={loading && !pendingData} /> ); }; diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.spec.tsx index cc060af6c6..1bacf076e8 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.spec.tsx @@ -14,12 +14,16 @@ describe('Analytics metric display components for the analytics app', () => { render( {}} + handleMetricChange={() => {}} + handleCustomRangeRequest={() => {}} pageViews={PAGE_VIEWS} metricName={METRIC_NAME} runReportResponse={runReportResponseHasViews} reportSlug="/en-US" + includedPaths={['/en-US']} propertyId="" startEndDates={{ start: '2023-03-26', end: '2023-03-27' }} + selectedMetric="screenPageViews" /> ); diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.tsx index 33ff491660..9882f7e818 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsMetricDisplays/AnalyticsMetricDisplay.tsx @@ -1,36 +1,50 @@ import ChartFooter from 'components/main-app/ChartFooter/ChartFooter'; import ChartHeader from 'components/main-app/ChartHeader/ChartHeader'; import ChartContent from '../ChartContent/ChartContent'; -import { RunReportResponse, StartEndDates, DateRangeType } from 'types'; +import { AnalyticsMetricType, RunReportResponse, StartEndDates, DateRangeType } from 'types'; import { getExternalUrl } from 'helpers/externalUrlHelpers/externalUrlHelpers'; interface Props { - handleDateRangeChange: Function; + handleDateRangeChange: (range: DateRangeType) => void; + handleMetricChange: (metric: AnalyticsMetricType) => void; + handleCustomRangeRequest: () => void; runReportResponse: RunReportResponse; reportSlug: string; + includedPaths?: string[]; + canOpenInGoogleAnalytics?: boolean; pageViews: number; metricName: string; error?: Error; propertyId: string; startEndDates: StartEndDates; selectedDateRange?: DateRangeType; + selectedMetric?: AnalyticsMetricType; + isLoading?: boolean; } const AnalyticsMetricDisplay = (props: Props) => { const { handleDateRangeChange, + handleMetricChange, + handleCustomRangeRequest, runReportResponse, reportSlug, + includedPaths = [], + canOpenInGoogleAnalytics = true, error, metricName, pageViews, propertyId, startEndDates, selectedDateRange, + selectedMetric, + isLoading = false, } = props; const propertyIdNumber = propertyId.split('/')[1] || ''; - const viewUrl = getExternalUrl(propertyIdNumber, { pagePath: reportSlug, startEndDates }); + const viewUrl = canOpenInGoogleAnalytics + ? getExternalUrl(propertyIdNumber, { pagePath: reportSlug, startEndDates }) + : ''; return ( <> @@ -38,12 +52,16 @@ const AnalyticsMetricDisplay = (props: Props) => { metricName={metricName ? metricName : ''} metricValue={Intl.NumberFormat('en', { notation: 'compact' }).format(pageViews)} handleChange={handleDateRangeChange} + handleMetricChange={handleMetricChange} + handleCustomRangeRequest={handleCustomRangeRequest} + startEndDates={startEndDates} selectedDateRange={selectedDateRange} + selectedMetric={selectedMetric} /> - + - + ); }; diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.spec.tsx index 1044ef3325..5b0422c217 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.spec.tsx @@ -39,4 +39,10 @@ describe('ChartContent component', () => { expect(noteText).toBeVisible(); }); + + it('shows a loading spinner while chart data is refreshing', () => { + render(); + + expect(screen.getByTestId('chart-loading-spinner')).toBeVisible(); + }); }); diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.styles.ts b/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.styles.ts index 52636465d5..9c1e0bf03d 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.styles.ts +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.styles.ts @@ -1,10 +1,13 @@ import { css } from 'emotion'; +import tokens from '@contentful/f36-tokens'; export const styles = { root: css({ width: '100%', + minHeight: '180px', display: 'flex', alignItems: 'center', justifyContent: 'center', + paddingTop: tokens.spacingXs, }), }; diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.tsx index e76b7e184f..9a7b7c3a5c 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartContent/ChartContent.tsx @@ -1,3 +1,4 @@ +import { Spinner } from '@contentful/f36-components'; import { Row, RunReportResponse } from 'types'; import Note from 'components/common/Note/Note'; import LineChart from 'components/main-app/LineChart/LineChart'; @@ -9,10 +10,11 @@ import { styles } from './ChartContent.styles'; interface Props { pageViewData: RunReportResponse; error?: Error; + isLoading?: boolean; } const ChartContent = (props: Props) => { - const { pageViewData, error } = props; + const { pageViewData, error, isLoading = false } = props; const parseRowViews = (): number[] => { return pageViewData.rows.map((r: Row) => +r.metricValues[0].value); @@ -27,6 +29,10 @@ const ChartContent = (props: Props) => { }; const renderChartContent = () => { + if (isLoading) { + return ; + } + if (error) { return ; } diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.spec.tsx index cf03396890..6fab433608 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.spec.tsx @@ -16,4 +16,20 @@ describe('Chart Footer for the analytics app', () => { expect(screen.getByText('Open in Google Analytics')).toBeVisible(); }); + + it('renders included paths summary for aggregated results', () => { + render( + + ); + + expect(screen.getByText('Included paths (3)')).toBeVisible(); + expect(screen.getByText('/category/my-post/')).toBeVisible(); + expect(screen.getByText('/interviews/my-post/')).toBeVisible(); + expect(screen.getByText('/news/my-post/')).toBeVisible(); + expect(screen.queryByText('Open in Google Analytics')).not.toBeInTheDocument(); + }); }); diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx index b0ae6f8f13..29ba14ea1a 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx @@ -4,17 +4,34 @@ import { ExternalLinkIcon } from '@contentful/f36-icons'; interface Props { slugName: string; viewUrl: string; + includedPaths?: string[]; } const ChartFooter = (props: Props) => { - const { slugName, viewUrl } = props; + const { slugName, viewUrl, includedPaths = [] } = props; + const isAggregated = includedPaths.length > 1; return ( - - Page path: {slugName} - - {viewUrl ? ( + {isAggregated ? ( + <> + + Included paths ({includedPaths.length}) + + + {includedPaths.map((path) => ( + + {path} + + ))} + + + ) : ( + + {slugName.startsWith('/') ? 'Page path' : 'Matching rules'}: {slugName} + + )} + {!isAggregated && viewUrl ? ( {}; +const handleMetricChange = () => {}; +const handleCustomRangeRequest = () => {}; +const startEndDates = { start: '2026-4-3', end: '2026-4-10' }; describe('Chart Header for the analytics app', () => { it('can render the metric value', () => { @@ -12,6 +15,9 @@ describe('Chart Header for the analytics app', () => { metricName={mockMetricName} metricValue={mockMetricValue} handleChange={handleChange} + handleMetricChange={handleMetricChange} + handleCustomRangeRequest={handleCustomRangeRequest} + startEndDates={startEndDates} /> ); @@ -24,9 +30,50 @@ describe('Chart Header for the analytics app', () => { metricName={mockMetricName} metricValue={mockMetricValue} handleChange={handleChange} + handleMetricChange={handleMetricChange} + handleCustomRangeRequest={handleCustomRangeRequest} + startEndDates={startEndDates} /> ); - expect(screen.getAllByRole('option').length).toBe(3); + expect(screen.getAllByRole('option').length).toBe(8); + expect(screen.getByRole('option', { name: 'Total views' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Unique views' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Last 90 days' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Last 12 months' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Custom range' })).toBeVisible(); + }); + + it('renders custom range summary and action when custom range is selected', () => { + render( + + ); + + expect(screen.getByText('2026-4-3 to 2026-4-10')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Choose dates' })).toBeVisible(); + }); + + it('renders unique views label when active users metric is selected', () => { + render( + + ); + + expect(screen.getByText('Unique Views')).toBeVisible(); }); }); diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.styles.ts b/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.styles.ts index 722266ac76..bd6543e6bf 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.styles.ts +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.styles.ts @@ -4,6 +4,34 @@ import tokens from '@contentful/f36-tokens'; export const styles = { root: css({ marginTop: tokens.spacing2Xs, - marginRight: tokens.spacing2Xs, + width: '168px', + }), + controls: css({ + alignItems: 'flex-end', + flexShrink: 0, + minHeight: '96px', + justifyContent: 'space-between', + }), + customRangeRow: css({ + marginTop: tokens.spacing2Xs, + gap: tokens.spacing2Xs, + flexDirection: 'column', + alignItems: 'flex-end', + width: '168px', + minHeight: '52px', + }), + customRangeRowVisible: css({ + visibility: 'visible', + }), + customRangeRowHidden: css({ + visibility: 'hidden', + pointerEvents: 'none', + }), + customRangeSummary: css({ + fontSize: tokens.fontSizeS, + textAlign: 'right', + }), + customRangeButton: css({ + width: '168px', }), }; diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx index 837c5156b3..2b42b4273e 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx @@ -1,42 +1,74 @@ import { useState, useEffect } from 'react'; -import { Box, DisplayText, Flex, Paragraph, Select } from '@contentful/f36-components'; -import { DateRangeType } from 'types'; +import { Box, Button, DisplayText, Flex, Paragraph, Select, Text } from '@contentful/f36-components'; +import { AnalyticsMetricType, DateRangeType, StartEndDates } from 'types'; +import { DATE_RANGE_SELECT_OPTIONS, DateRange } from 'helpers/DateRangeHelpers/DateRangeHelpers'; import { styles } from './ChartHeader.styles'; interface Props { metricName: string; metricValue: string; - handleChange: Function; + handleChange: (range: DateRangeType) => void; + handleMetricChange: (metric: AnalyticsMetricType) => void; + handleCustomRangeRequest: () => void; + startEndDates: StartEndDates; selectedDateRange?: DateRangeType; + selectedMetric?: AnalyticsMetricType; } const getMetricDisplayString = (_metricName: string) => { switch (_metricName) { case 'screenPageViews': return 'Total Views'; + case 'activeUsers': + return 'Unique Views'; default: return 'Undetermined metric'; } }; const ChartHeader = (props: Props) => { - const { metricName, metricValue, handleChange, selectedDateRange } = props; - + const { + metricName, + metricValue, + handleChange, + handleMetricChange, + handleCustomRangeRequest, + startEndDates, + selectedDateRange, + selectedMetric = 'screenPageViews', + } = props; const [dateSelection, setDateSelection] = useState('lastWeek'); + const [metricSelection, setMetricSelection] = useState(selectedMetric); const handleOnChange = (event: React.ChangeEvent) => { - setDateSelection(event.target.value as DateRangeType); - handleChange(event.target.value as DateRangeType); + const nextDateRange = event.target.value as DateRangeType; + if (nextDateRange === DateRange.Custom) { + handleCustomRangeRequest(); + return; + } + + setDateSelection(nextDateRange); + handleChange(nextDateRange); + }; + + const handleMetricSelect = (event: React.ChangeEvent) => { + const nextMetric = event.target.value as AnalyticsMetricType; + setMetricSelection(nextMetric); + handleMetricChange(nextMetric); }; useEffect(() => { if (selectedDateRange) setDateSelection(selectedDateRange); }, [selectedDateRange]); + useEffect(() => { + setMetricSelection(selectedMetric); + }, [selectedMetric]); + return ( @@ -45,16 +77,46 @@ const ChartHeader = (props: Props) => { {getMetricDisplayString(metricName)} - + + + + + + {startEndDates.start} to {startEndDates.end} + + + + ); }; diff --git a/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.spec.ts b/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.spec.ts index 937857f063..1fe657165a 100644 --- a/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.spec.ts +++ b/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.spec.ts @@ -45,4 +45,30 @@ describe('handle date range helper', () => { expect(endDay).toBe(today.getDate()); expect(startDay).toBe(yesterday.getDate()); }); + + it('formats dates correctly for quarter range', () => { + const { startTime, endTime } = getDateRangeTime('lastQuarter'); + const { startDay, endDay } = getParsedDateRangeDate('lastQuarter'); + + expect(Math.round((endTime - startTime) / DAY_IN_MS)).toBe( + RANGE_OPTIONS.lastQuarter.startDaysAgo + ); + const today = new Date(); + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - RANGE_OPTIONS.lastQuarter.startDaysAgo); + expect(endDay).toBe(today.getDate()); + expect(startDay).toBe(ninetyDaysAgo.getDate()); + }); + + it('formats dates correctly for year range', () => { + const { startTime, endTime } = getDateRangeTime('lastYear'); + const { startDay, endDay } = getParsedDateRangeDate('lastYear'); + + expect(Math.round((endTime - startTime) / DAY_IN_MS)).toBe(RANGE_OPTIONS.lastYear.startDaysAgo); + const today = new Date(); + const oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - RANGE_OPTIONS.lastYear.startDaysAgo); + expect(endDay).toBe(today.getDate()); + expect(startDay).toBe(oneYearAgo.getDate()); + }); }); diff --git a/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.ts b/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.ts index e33e6ebbbb..89956ec89a 100644 --- a/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.ts +++ b/apps/google-analytics-4/frontend/src/helpers/DateRangeHelpers/DateRangeHelpers.ts @@ -1,21 +1,35 @@ import { DateRangeType } from 'types'; export enum DateRange { - LastWeek = 'lastWeek', LastDay = 'lastDay', + LastWeek = 'lastWeek', LastMonth = 'lastMonth', + LastQuarter = 'lastQuarter', + LastYear = 'lastYear', + Custom = 'custom', } export const RANGE_OPTIONS = { lastDay: { startDaysAgo: 1, endDaysAgo: 0 }, lastWeek: { startDaysAgo: 7, endDaysAgo: 0 }, lastMonth: { startDaysAgo: 28, endDaysAgo: 0 }, + lastQuarter: { startDaysAgo: 90, endDaysAgo: 0 }, + lastYear: { startDaysAgo: 365, endDaysAgo: 0 }, }; +export const DATE_RANGE_SELECT_OPTIONS: { value: DateRangeType; label: string }[] = [ + { value: DateRange.LastDay, label: 'Last 24 hours' }, + { value: DateRange.LastWeek, label: 'Last 7 days' }, + { value: DateRange.LastMonth, label: 'Last 28 days' }, + { value: DateRange.LastQuarter, label: 'Last 90 days' }, + { value: DateRange.LastYear, label: 'Last 12 months' }, + { value: DateRange.Custom, label: 'Custom range' }, +]; + const DAY_IN_MS = 1000 * 60 * 60 * 24; // date should be local to user in format of YYYY-MM-DD -const formatDate = (date: Date) => { +export const formatDate = (date: Date) => { const year = date.getFullYear(); // months start at 0 const month = date.getMonth() + 1; @@ -24,7 +38,17 @@ const formatDate = (date: Date) => { return `${year}-${month}-${day}`; }; +export const parseDateString = (dateString: string) => { + const [year, month, day] = dateString.split('-').map(Number); + + return new Date(year, month - 1, day); +}; + const getRangeDates = (dateRange: DateRangeType) => { + if (dateRange === DateRange.Custom) { + return getRangeDates(DateRange.LastWeek); + } + const selectedRange = RANGE_OPTIONS[dateRange]; const today = new Date().valueOf(); diff --git a/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts b/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts new file mode 100644 index 0000000000..6527c1054c --- /dev/null +++ b/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts @@ -0,0 +1,61 @@ +import { ContentTypeRule, ContentTypeRules, ContentTypes, ContentTypeValue } from 'types'; + +const createRuleId = () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `rule_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + +export const createDefaultRule = (contentTypeId = ''): ContentTypeRule => ({ + id: createRuleId(), + contentTypeId, + slugField: '', + urlPrefix: '', + additionalFieldIds: [], + enableAdvancedMatching: false, + pathPattern: '', + matchDimension: 'unifiedPagePathScreen', + matchType: 'EXACT', +}); + +export const createRuleFromContentType = ( + contentTypeId: string, + value: ContentTypeValue +): ContentTypeRule => ({ + id: createRuleId(), + contentTypeId, + slugField: value.slugField, + urlPrefix: value.urlPrefix, + additionalFieldIds: value.additionalFieldIds || [], + enableAdvancedMatching: value.enableAdvancedMatching || false, + pathPattern: value.pathPattern || '', + matchDimension: value.matchDimension || 'unifiedPagePathScreen', + matchType: value.matchType || 'EXACT', +}); + +export const migrateContentTypesToRules = (contentTypes?: ContentTypes): ContentTypeRules => { + if (!contentTypes) return []; + + return Object.entries(contentTypes).map(([contentTypeId, value]) => + createRuleFromContentType(contentTypeId, value) + ); +}; + +export const normalizeContentTypeRules = ( + contentTypeRules?: ContentTypeRules, + legacyContentTypes?: ContentTypes +): ContentTypeRules => { + if (contentTypeRules?.length) { + return contentTypeRules.map((rule) => ({ + ...createDefaultRule(rule.contentTypeId), + ...rule, + id: rule.id || createRuleId(), + })); + } + + return migrateContentTypesToRules(legacyContentTypes); +}; + +export const getUniqueContentTypeIds = (contentTypeRules: ContentTypeRules) => + Array.from( + new Set(contentTypeRules.map((rule) => rule.contentTypeId).filter((contentTypeId) => contentTypeId)) + ); diff --git a/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx b/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx new file mode 100644 index 0000000000..0a6ca7715e --- /dev/null +++ b/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { ContentEntitySys, SidebarExtensionSDK } from '@contentful/app-sdk'; +import { AppInstallationParameters, ContentTypeRule } from 'types'; +import { getReportSlug } from 'utils/getReportSlug'; + +interface ResolvedSidebarRule extends ContentTypeRule { + reportSlug: string; + slugFieldValue: string | object; + slugFieldIsConfigured: boolean; + contentTypeHasSlugField: boolean; + contentTypeHasAllFields: boolean; + isValidRule: boolean; +} + +const SLUG_FIELD_INPUT_DELAY = 500; + +export const useSidebarRules = (slugFieldRules: ContentTypeRule[]) => { + const sdk = useSDK(); + const { forceTrailingSlash } = sdk.parameters.installation as AppInstallationParameters; + const entryFields = sdk.entry?.fields ?? {}; + + const [isPublished, setIsPublished] = useState(false); + const [fieldValues, setFieldValues] = useState>({}); + const [debouncedFieldValues, setDebouncedFieldValues] = useState>({}); + const [haveLoadedFieldValues, setHaveLoadedFieldValues] = useState(false); + const relevantFieldIds = useMemo( + () => + Array.from( + new Set( + slugFieldRules + .flatMap((rule) => [rule.slugField, ...(rule.additionalFieldIds || [])]) + .filter((fieldId) => fieldId) + ) + ), + [slugFieldRules] + ); + + useEffect(() => { + const timeout = setTimeout(() => setDebouncedFieldValues(fieldValues), SLUG_FIELD_INPUT_DELAY); + return () => clearTimeout(timeout); + }, [fieldValues]); + + useEffect(() => { + const handlePublishedStatus = (sys: ContentEntitySys) => { + setIsPublished(Boolean(sys.publishedAt)); + }; + + if (!sdk.entry?.onSysChanged) return; + return sdk.entry.onSysChanged((sys) => handlePublishedStatus(sys)); + }, [sdk.entry]); + + useEffect(() => { + const unsubscribers: Array<() => void> = []; + const initialFieldValues: Record = {}; + + setHaveLoadedFieldValues(false); + + relevantFieldIds.forEach((fieldId) => { + const fieldApi = (entryFields as Record)[fieldId]; + if (!fieldApi) return; + + if (typeof fieldApi.getValue === 'function') { + initialFieldValues[fieldId] = fieldApi.getValue() ?? ''; + } + + if (typeof fieldApi.onValueChanged === 'function') { + const detach = fieldApi.onValueChanged((value: string | object) => { + setFieldValues((prev) => ({ ...prev, [fieldId]: value ?? '' })); + }); + if (typeof detach === 'function') unsubscribers.push(detach); + } + }); + + setFieldValues(initialFieldValues); + setDebouncedFieldValues(initialFieldValues); + setHaveLoadedFieldValues(true); + + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); + }, [entryFields, relevantFieldIds]); + + const resolvedRules = useMemo( + () => + slugFieldRules.map((rule) => { + const slugFieldValue = debouncedFieldValues[rule.slugField] ?? ''; + const slugFieldIsConfigured = Boolean(rule.slugField); + const contentTypeHasSlugField = rule.slugField in entryFields; + const additionalFieldIds = rule.additionalFieldIds || []; + const contentTypeHasAllFields = + contentTypeHasSlugField && + additionalFieldIds.every((fieldId) => fieldId in entryFields); + const tokenValues = { + slug: slugFieldValue, + ...Object.fromEntries( + additionalFieldIds.map((fieldId) => [fieldId, debouncedFieldValues[fieldId] ?? '']) + ), + }; + const isValidRule = + slugFieldIsConfigured && + contentTypeHasAllFields && + Object.values(tokenValues).every((value) => Boolean(value)) && + isPublished; + + return { + ...rule, + slugFieldValue, + slugFieldIsConfigured, + contentTypeHasSlugField, + contentTypeHasAllFields, + isValidRule, + reportSlug: getReportSlug(rule, tokenValues, forceTrailingSlash), + }; + }), + [debouncedFieldValues, entryFields, forceTrailingSlash, isPublished, slugFieldRules] + ); + + const validRules = useMemo( + () => resolvedRules.filter((rule) => rule.isValidRule), + [resolvedRules] + ); + const fallbackRule = resolvedRules[0]; + const summaryLabel = useMemo( + () => + validRules.length <= 1 + ? validRules[0]?.reportSlug || fallbackRule?.reportSlug || '' + : `${validRules.length} rules configured`, + [fallbackRule?.reportSlug, validRules] + ); + + return { + isPublished, + haveLoadedFieldValues, + resolvedRules, + validRules, + summaryLabel, + isContentTypeWarning: validRules.length === 0, + warningRule: fallbackRule, + }; +}; diff --git a/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.spec.tsx b/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.spec.tsx index 1005627551..d6a57ffd66 100644 --- a/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.spec.tsx +++ b/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.spec.tsx @@ -209,4 +209,35 @@ describe('useSidebarSlug hook', () => { expect(getByText('slugFieldValue: /fieldValue')).toBeVisible(); expect(getByText('isContentTypeWarning: false')).toBeVisible(); }); + + it('uses the path pattern when configured', () => { + mockInstallationParams.parameters.installation.forceTrailingSlash = false; + + vi.spyOn(useSDK, 'useSDK').mockImplementation( + () => + ({ + ...mockInstallationParams, + ...vi.importActual('@contentful/react-apps-toolkit'), + entry: { + ...vi.importActual('@contentful/react-apps-toolkit'), + fields: { slugField: {} }, + onSysChanged: vi.fn((cb) => + cb({ + publishedAt: '2020202', + } as unknown as EntrySys) + ), + }, + } as any) + ); + vi.spyOn(getFieldValue, 'default').mockImplementation(() => 'fieldValue'); + const slugFieldInfo = { + slugField: 'slugField', + urlPrefix: '/en-US', + pathPattern: '/blog/{slug}', + }; + + render(); + + expect(getByText('reportSlug: /blog/fieldValue')).toBeVisible(); + }); }); diff --git a/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx b/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx index 9130702f0c..12a19693c2 100644 --- a/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx +++ b/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react'; import { ContentTypeValue } from 'types'; import { useSDK } from '@contentful/react-apps-toolkit'; import { ContentEntitySys, SidebarExtensionSDK } from '@contentful/app-sdk'; -import { pathJoin } from 'utils/pathJoin'; import useGetFieldValue from '../useGetFieldValue'; import { AppInstallationParameters } from 'types'; +import { getReportSlug } from 'utils/getReportSlug'; const SLUG_FIELD_INPUT_DELAY = 500; @@ -13,7 +13,7 @@ export const useSidebarSlug = (slugFieldInfo: ContentTypeValue) => { const { forceTrailingSlash } = sdk.parameters.installation as AppInstallationParameters; - const { slugField, urlPrefix } = slugFieldInfo; + const { slugField } = slugFieldInfo; const slugFieldValue = useGetFieldValue(slugField); const [isPublished, setIsPublished] = useState(false); @@ -36,9 +36,7 @@ export const useSidebarSlug = (slugFieldInfo: ContentTypeValue) => { sdk.entry.onSysChanged((sys) => handlePublishedStatus(sys)); }, [sdk.entry]); - const reportSlug = `/${pathJoin(urlPrefix || '', debouncedSlugFieldValue || '')}${ - forceTrailingSlash ? '/' : '' - }`; + const reportSlug = getReportSlug(slugFieldInfo, debouncedSlugFieldValue || '', forceTrailingSlash); const slugFieldIsConfigured = Boolean(slugField); const contentTypeHasSlugField = slugField in sdk.entry.fields; diff --git a/apps/google-analytics-4/frontend/src/locations/Dialog.spec.tsx b/apps/google-analytics-4/frontend/src/locations/Dialog.spec.tsx index a3cfb9c910..0453fe4bed 100644 --- a/apps/google-analytics-4/frontend/src/locations/Dialog.spec.tsx +++ b/apps/google-analytics-4/frontend/src/locations/Dialog.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; import Dialog from './Dialog'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { mockCma, mockSdk } from '../../test/mocks'; import { vi } from 'vitest'; @@ -10,9 +10,18 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ })); describe('Dialog component', () => { - it('Component text exists', () => { - const { getByText } = render(); + it('renders custom date range controls', () => { + mockSdk.parameters.invocation = { + mode: 'customDateRange', + startDate: '2026-4-3', + endDate: '2026-4-10', + }; - expect(getByText('Hello Dialog Component (AppId: test-app)')).toBeInTheDocument(); + render(); + + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); }); diff --git a/apps/google-analytics-4/frontend/src/locations/Dialog.tsx b/apps/google-analytics-4/frontend/src/locations/Dialog.tsx index ba68d1ede7..b509efc839 100644 --- a/apps/google-analytics-4/frontend/src/locations/Dialog.tsx +++ b/apps/google-analytics-4/frontend/src/locations/Dialog.tsx @@ -1,17 +1,81 @@ -import React from 'react'; -import { Paragraph } from '@contentful/f36-components'; +import React, { useMemo, useState } from 'react'; +import { Box, Button, Datepicker, Flex, FormControl } from '@contentful/f36-components'; import { DialogExtensionSDK } from '@contentful/app-sdk'; -import { /* useCMA, */ useSDK } from '@contentful/react-apps-toolkit'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { CustomRangeDialogInvocationParams, StartEndDates } from 'types'; +import { formatDate, parseDateString } from 'helpers/DateRangeHelpers/DateRangeHelpers'; const Dialog = () => { - const sdk = useSDK(); - /* - To use the cma, inject it as follows. - If it is not needed, you can remove the next line. - */ - // const cma = useCMA(); - - return Hello Dialog Component (AppId: {sdk.ids.app}); + const sdk = useSDK>(); + const invocationParams = sdk.parameters.invocation; + + const initialDates = useMemo(() => { + if (invocationParams?.mode === 'customDateRange') { + return { + start: invocationParams.startDate, + end: invocationParams.endDate, + }; + } + + const today = new Date(); + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(today.getDate() - 7); + + return { + start: formatDate(oneWeekAgo), + end: formatDate(today), + }; + }, [invocationParams]); + + const [startDate, setStartDate] = useState(parseDateString(initialDates.start)); + const [endDate, setEndDate] = useState(parseDateString(initialDates.end)); + + return ( + + + + + From + + + + To + + + + + + + + + + ); }; export default Dialog; diff --git a/apps/google-analytics-4/frontend/src/locations/Sidebar.tsx b/apps/google-analytics-4/frontend/src/locations/Sidebar.tsx index e3d607f5a4..b75bf0015b 100644 --- a/apps/google-analytics-4/frontend/src/locations/Sidebar.tsx +++ b/apps/google-analytics-4/frontend/src/locations/Sidebar.tsx @@ -2,27 +2,46 @@ import AnalyticsApp from 'components/main-app/AnalyticsApp/AnalyticsApp'; import { useSDK } from '@contentful/react-apps-toolkit'; import { SidebarExtensionSDK } from '@contentful/app-sdk'; import { useApi } from 'hooks/useApi'; -import { AppInstallationParameters } from 'types'; +import { AppInstallationParameters, CustomRangeDialogResult, StartEndDates } from 'types'; import Note from 'components/common/Note/Note'; import { getMissingParamsMsg } from 'components/main-app/constants/noteMessages'; import { AppConfigPageHyperLink } from 'components/main-app/ErrorDisplay/CommonErrorDisplays'; +import { normalizeContentTypeRules } from 'helpers/contentTypeRules/contentTypeRules'; const Sidebar = () => { const sdk = useSDK(); - const { serviceAccountKeyId, propertyId, contentTypes } = sdk.parameters + const { serviceAccountKeyId, propertyId, contentTypes, contentTypeRules } = sdk.parameters .installation as AppInstallationParameters; const currentContentType = sdk.contentType.sys.id; - const slugFieldInfo = (contentTypes && contentTypes[currentContentType]) ?? { - slugField: '', - urlPrefix: '', - }; + const slugFieldRules = normalizeContentTypeRules(contentTypeRules, contentTypes).filter( + (rule) => rule.contentTypeId === currentContentType + ); const api = useApi(serviceAccountKeyId); const hasInstallationParams = serviceAccountKeyId && propertyId; + const openCustomRangeDialog = async ( + startEndDates: StartEndDates + ): Promise => { + const result = await sdk.dialogs.openCurrentApp({ + title: 'Custom date range', + width: 'medium', + minHeight: '560px', + shouldCloseOnOverlayClick: true, + shouldCloseOnEscapePress: true, + parameters: { + mode: 'customDateRange', + startDate: startEndDates.start, + endDate: startEndDates.end, + }, + }); + + return result as CustomRangeDialogResult | undefined; + }; + if (!hasInstallationParams) { const bodyMsg = getMissingParamsMsg(!serviceAccountKeyId, !propertyId); return } variant="warning" />; @@ -30,7 +49,12 @@ const Sidebar = () => { return ( <> - + ); }; diff --git a/apps/google-analytics-4/frontend/src/types.ts b/apps/google-analytics-4/frontend/src/types.ts index f175b51bd7..a07f92b793 100644 --- a/apps/google-analytics-4/frontend/src/types.ts +++ b/apps/google-analytics-4/frontend/src/types.ts @@ -2,7 +2,8 @@ import { IdsAPI } from '@contentful/app-sdk'; export interface AppInstallationParameters { serviceAccountKeyId: ServiceAccountKeyId; - contentTypes: ContentTypes; + contentTypes?: ContentTypes; + contentTypeRules?: ContentTypeRule[]; propertyId: string; forceTrailingSlash: boolean; } @@ -78,21 +79,43 @@ export interface RunReportResponse { propertyQuota: null; kind: string; } + +export type GAMatchDimension = 'unifiedPagePathScreen' | 'pagePathPlusQueryString'; +export type GAStringMatchType = 'EXACT' | 'PARTIAL_REGEXP'; + export interface RunReportParamsType { propertyId: string; slug: string; + matchDimension?: GAMatchDimension; + matchType?: GAStringMatchType; startDate: string; endDate: string; dimensions: string | string[]; metrics: string | string[]; } -export type DateRangeType = 'lastWeek' | 'lastDay' | 'lastMonth'; +export type DateRangeType = + | 'lastDay' + | 'lastWeek' + | 'lastMonth' + | 'lastQuarter' + | 'lastYear' + | 'custom'; + +export type AnalyticsMetricType = 'screenPageViews' | 'activeUsers'; export interface StartEndDates { start: string; end: string; } + +export interface CustomRangeDialogInvocationParams { + mode: 'customDateRange'; + startDate: string; + endDate: string; +} + +export interface CustomRangeDialogResult extends StartEndDates {} export interface AccountSummariesType { displayName: string; name: string; @@ -110,6 +133,16 @@ export interface PropertySummariesType { export interface ContentTypeValue { slugField: string; urlPrefix: string; + additionalFieldIds?: string[]; + enableAdvancedMatching?: boolean; + pathPattern?: string; + matchDimension?: GAMatchDimension; + matchType?: GAStringMatchType; +} + +export interface ContentTypeRule extends ContentTypeValue { + id: string; + contentTypeId: string; } export interface ContentTypes { @@ -117,6 +150,7 @@ export interface ContentTypes { } export type ContentTypeEntries = [string, ContentTypeValue][]; +export type ContentTypeRules = ContentTypeRule[]; interface AllContentTypeValue { name: string; diff --git a/apps/google-analytics-4/frontend/src/utils/contentTypeMatching.ts b/apps/google-analytics-4/frontend/src/utils/contentTypeMatching.ts new file mode 100644 index 0000000000..cfd7a64062 --- /dev/null +++ b/apps/google-analytics-4/frontend/src/utils/contentTypeMatching.ts @@ -0,0 +1,9 @@ +import { ContentTypeValue } from 'types'; + +export const hasAdvancedMatchingConfigured = (contentTypeValue: ContentTypeValue) => + Boolean( + contentTypeValue.enableAdvancedMatching || + contentTypeValue.pathPattern?.trim() || + contentTypeValue.matchDimension === 'pagePathPlusQueryString' || + contentTypeValue.matchType === 'PARTIAL_REGEXP' + ); diff --git a/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts b/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts new file mode 100644 index 0000000000..6fb837d50b --- /dev/null +++ b/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { getReportSlug, pathPatternPreview } from './getReportSlug'; + +describe('getReportSlug', () => { + it('supports advanced patterns with multiple field tokens', () => { + const reportSlug = getReportSlug( + { + slugField: 'slug', + urlPrefix: '', + enableAdvancedMatching: true, + additionalFieldIds: ['sectionSlug'], + pathPattern: '/{sectionSlug}/{slug}', + }, + { + slug: 'market-insights', + sectionSlug: 'guides', + }, + true + ); + + expect(reportSlug).toBe('/guides/market-insights/'); + }); + + it('supports deeper composed paths with multiple selected properties', () => { + const reportSlug = getReportSlug( + { + slugField: 'slug', + urlPrefix: '', + enableAdvancedMatching: true, + additionalFieldIds: ['regionSlug', 'citySlug'], + pathPattern: '/{regionSlug}/{citySlug}/{slug}', + }, + { + slug: 'luxury-homes', + regionSlug: 'north-america', + citySlug: 'denver', + }, + true + ); + + expect(reportSlug).toBe('/north-america/denver/luxury-homes/'); + }); + + it('builds previews for additional field tokens', () => { + expect(pathPatternPreview('/{sectionSlug}/{slug}', ['sectionSlug'])).toBe( + '/example-sectionSlug/example-slug' + ); + }); +}); diff --git a/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts b/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts new file mode 100644 index 0000000000..3e82f526b1 --- /dev/null +++ b/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts @@ -0,0 +1,48 @@ +import { ContentTypeValue } from 'types'; +import { hasAdvancedMatchingConfigured } from 'utils/contentTypeMatching'; +import { pathJoin } from 'utils/pathJoin'; + +const SLUG_TOKEN = '{slug}'; +const TOKEN_REGEX = /\{([^}]+)\}/g; + +type FieldValueMap = Record; + +const getPatternValue = (token: string, fieldValues: FieldValueMap) => { + const normalizedFieldValue = + token === 'slug' ? fieldValues.slug : fieldValues[token]; + + return pathJoin(normalizedFieldValue); +}; + +const normalizePattern = (pathPattern: string, fieldValues: FieldValueMap) => { + return pathPattern.replace(TOKEN_REGEX, (_match, token) => getPatternValue(token, fieldValues)); +}; + +export const getReportSlug = ( + contentTypeValue: ContentTypeValue, + slugFieldValue: string | object, + forceTrailingSlash: boolean +) => { + const { pathPattern, urlPrefix } = contentTypeValue; + const fieldValues = + typeof slugFieldValue === 'object' && !Array.isArray(slugFieldValue) + ? ({ slug: '', ...slugFieldValue } as FieldValueMap) + : ({ slug: slugFieldValue } as FieldValueMap); + const basePath = hasAdvancedMatchingConfigured(contentTypeValue) && pathPattern?.trim() + ? normalizePattern(pathPattern, fieldValues) + : pathJoin(urlPrefix || '', fieldValues.slug || ''); + + return `/${pathJoin(basePath)}${forceTrailingSlash ? '/' : ''}`; +}; + +export const pathPatternPreview = (pathPattern: string, additionalFieldIds: string[] = []) => { + const fieldValues: FieldValueMap = { + slug: 'example-slug', + }; + + additionalFieldIds.forEach((fieldId) => { + fieldValues[fieldId] = `example-${fieldId}`; + }); + + return `/${pathJoin(normalizePattern(pathPattern, fieldValues))}`; +}; diff --git a/apps/google-analytics-4/frontend/test/mocks/mockSdk.ts b/apps/google-analytics-4/frontend/test/mocks/mockSdk.ts index eb22359749..11d7a832fa 100644 --- a/apps/google-analytics-4/frontend/test/mocks/mockSdk.ts +++ b/apps/google-analytics-4/frontend/test/mocks/mockSdk.ts @@ -44,9 +44,14 @@ const mockSdk: any = { notifier: { error: vi.fn(), }, + dialogs: { + openCurrentApp: vi.fn(), + }, + close: vi.fn(), parameters: { installation: {}, instance: {}, + invocation: {}, }, location: { is: vi.fn().mockReturnValue(true), diff --git a/apps/google-analytics-4/lambda/src/controllers/apiController.ts b/apps/google-analytics-4/lambda/src/controllers/apiController.ts index 0fa77f0134..95222a1252 100644 --- a/apps/google-analytics-4/lambda/src/controllers/apiController.ts +++ b/apps/google-analytics-4/lambda/src/controllers/apiController.ts @@ -66,7 +66,7 @@ const ApiController = { try { const serviceAccountKey = requireServiceAccountKey(req.serviceAccountKey); - const { propertyId, slug, startDate, endDate, dimensions, metrics } = + const { propertyId, slug, matchDimension, matchType, startDate, endDate, dimensions, metrics } = req.query as unknown as RunReportParamsType; const googleApi = GoogleApiService.fromServiceAccountKeyFile(serviceAccountKey); const result = @@ -74,12 +74,21 @@ const ApiController = { ? await googleApi.runReport( propertyId, slug, + matchDimension, + matchType, startDate, endDate, formatArrays(dimensions), formatArrays(metrics) ) - : await googleApi.runReport(propertyId, slug, startDate, endDate); + : await googleApi.runReport( + propertyId, + slug, + matchDimension, + matchType, + startDate, + endDate + ); res.status(200).json(result); } catch (err) { next(err); diff --git a/apps/google-analytics-4/lambda/src/services/googleApi.spec.ts b/apps/google-analytics-4/lambda/src/services/googleApi.spec.ts index 7862874d65..50649252c7 100644 --- a/apps/google-analytics-4/lambda/src/services/googleApi.spec.ts +++ b/apps/google-analytics-4/lambda/src/services/googleApi.spec.ts @@ -2,7 +2,7 @@ import { AnalyticsAdminServiceClient } from '@google-analytics/admin'; import { BetaAnalyticsDataClient } from '@google-analytics/data'; import { expect } from 'chai'; import { Status } from 'google-gax'; -import { SinonStubbedInstance } from 'sinon'; +import sinon, { SinonStubbedInstance } from 'sinon'; import { mockAccountSummary, mockAnalyticsAdminServiceClient, @@ -49,6 +49,42 @@ describe('GoogleApiService', () => { expect(error).to.eq(someError); }); }); + + describe('runReport', () => { + let runReportSpy: sinon.SinonSpy; + + beforeEach(() => { + runReportSpy = sinon.spy(() => + Promise.resolve([ + { + rows: [], + }, + ]) + ); + mockDataClient = { + runReport: runReportSpy, + } as unknown as SinonStubbedInstance; + googleApi = new GoogleApiService(validServiceAccountKeyFile, mockAdminClient, mockDataClient); + }); + + it('uses the requested GA4 match dimension in the filter', async () => { + await googleApi.runReport( + 'properties/123', + '/article?articleId=360054483454', + 'pagePathPlusQueryString', + 'PARTIAL_REGEXP', + '2026-04-01', + '2026-04-06' + ); + + expect(runReportSpy.firstCall.args[0].dimensionFilter.filter.fieldName).to.equal( + 'pagePathPlusQueryString' + ); + expect(runReportSpy.firstCall.args[0].dimensionFilter.filter.stringFilter.matchType).to.equal( + 'PARTIAL_REGEXP' + ); + }); + }); }); describe('throwGoogleApiError', () => { diff --git a/apps/google-analytics-4/lambda/src/services/googleApiService.ts b/apps/google-analytics-4/lambda/src/services/googleApiService.ts index 0aaedd46e8..cfeb976b79 100644 --- a/apps/google-analytics-4/lambda/src/services/googleApiService.ts +++ b/apps/google-analytics-4/lambda/src/services/googleApiService.ts @@ -1,6 +1,11 @@ import { AnalyticsAdminServiceClient, protos } from '@google-analytics/admin'; import { GoogleAuthOptions } from 'google-auth-library'; -import { ServiceAccountKeyFile, ReportRowType } from '../types'; +import { + ServiceAccountKeyFile, + ReportRowType, + GAMatchDimension, + GAStringMatchType, +} from '../types'; import { BetaAnalyticsDataClient } from '@google-analytics/data'; import { isGoogleError, @@ -108,6 +113,8 @@ export class GoogleApiService { async runReport( property: string, slug: string, + matchDimension: GAMatchDimension = 'unifiedPagePathScreen', + matchType: GAStringMatchType = 'EXACT', startDate?: string, endDate?: string, dimensions?: string[], @@ -134,8 +141,9 @@ export class GoogleApiService { }), dimensionFilter: { filter: { - fieldName: 'unifiedPagePathScreen', + fieldName: matchDimension, stringFilter: { + matchType, value: slug, }, }, diff --git a/apps/google-analytics-4/lambda/src/types.ts b/apps/google-analytics-4/lambda/src/types.ts index 9a91117689..fb192197b9 100644 --- a/apps/google-analytics-4/lambda/src/types.ts +++ b/apps/google-analytics-4/lambda/src/types.ts @@ -18,9 +18,14 @@ export interface ServiceAccountKeyId { clientId: string; } +export type GAMatchDimension = 'unifiedPagePathScreen' | 'pagePathPlusQueryString'; +export type GAStringMatchType = 'EXACT' | 'PARTIAL_REGEXP'; + export interface RunReportParamsType { propertyId: string; slug: string; + matchDimension?: GAMatchDimension; + matchType?: GAStringMatchType; startDate: string; endDate: string; dimensions: string | string[]; From 0baa32110517b1cf5308da707ad59fd8ecd8c44b Mon Sep 17 00:00:00 2001 From: zachary Date: Thu, 16 Apr 2026 09:41:08 -0600 Subject: [PATCH 2/8] Format GA4 files for CircleCI --- .../GoogleAnalyticsConfigPage.tsx | 5 ++- .../AssignContentTypeCard.spec.tsx | 6 +-- .../AssignContentTypeCard.tsx | 6 ++- .../AssignContentTypeRow.spec.tsx | 20 +++------ .../AssignContentTypeRow.tsx | 43 ++++++++++++------- .../AssignContentTypeSection.tsx | 8 ++-- .../AnalyticsApp/AnalyticsApp.spec.tsx | 11 ++++- .../main-app/AnalyticsApp/AnalyticsApp.tsx | 16 ++++--- .../main-app/ChartFooter/ChartFooter.tsx | 6 ++- .../main-app/ChartHeader/ChartHeader.tsx | 14 +++++- .../contentTypeRules/contentTypeRules.ts | 4 +- .../hooks/useSidebarRules/useSidebarRules.tsx | 7 +-- .../hooks/useSidebarSlug/useSidebarSlug.tsx | 6 ++- .../frontend/src/locations/Dialog.tsx | 12 +++++- .../frontend/src/utils/getReportSlug.ts | 10 ++--- .../lambda/src/controllers/apiController.ts | 12 +++++- 16 files changed, 121 insertions(+), 65 deletions(-) diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx index 4d4fd2fbea..41e5b88296 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/GoogleAnalyticsConfigPage/GoogleAnalyticsConfigPage.tsx @@ -18,7 +18,10 @@ import { config } from 'config'; import { convertServiceAccountKeyToServiceAccountKeyId } from 'utils/serviceAccountKey'; import HyperLink from 'components/common/HyperLink/HyperLink'; import { ExternalLinkIcon } from '@contentful/f36-icons'; -import { getUniqueContentTypeIds, normalizeContentTypeRules } from 'helpers/contentTypeRules/contentTypeRules'; +import { + getUniqueContentTypeIds, + normalizeContentTypeRules, +} from 'helpers/contentTypeRules/contentTypeRules'; export default function GoogleAnalyticsConfigPage() { const [accountsSummaries, setAccountsSummaries] = useState([]); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx index 980c54b7ec..80e90ce823 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx @@ -1,9 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { - AllContentTypes, - AllContentTypeEntries, - ContentTypeRules, -} from '../../../types'; +import { AllContentTypes, AllContentTypeEntries, ContentTypeRules } from '../../../types'; import AssignContentTypeCard from 'components/config-screen/assign-content-type/AssignContentTypeCard'; const allContentTypes: AllContentTypes = { diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx index 31d1fa25b1..21fdfa1d01 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx @@ -10,7 +10,11 @@ interface AssignContentTypeCardProps { allContentTypeEntries: AllContentTypeEntries; contentTypeRules: ContentTypeRules; onContentTypeChange: (ruleId: string, newContentTypeId: string) => void; - onContentTypeFieldChange: (ruleId: string, field: string, value: string | boolean | string[]) => void; + onContentTypeFieldChange: ( + ruleId: string, + field: string, + value: string | boolean | string[] + ) => void; onRemoveContentType: (ruleId: string) => void; currentEditorInterface: Partial; originalContentTypeRules: ContentTypeRules; diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx index d1fdbef964..0406fbc90f 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx @@ -113,12 +113,7 @@ describe('Assign Content Type Card for Config Screen', () => { it('calls change handler when content type selection is changed', async () => { const user = userEvent.setup(); - render( - - ); + render(); await user.selectOptions(screen.getByTestId('contentTypeSelect'), ['course']); @@ -127,12 +122,7 @@ describe('Assign Content Type Card for Config Screen', () => { it('calls field change handler when slug field selection is changed', async () => { const user = userEvent.setup(); - render( - - ); + render(); await user.selectOptions(screen.getByTestId('slugFieldSelect'), ['slug']); @@ -165,7 +155,11 @@ describe('Assign Content Type Card for Config Screen', () => { await user.click(screen.getByTestId('advancedMatchingToggle')); expect(screen.getByTestId('advancedMatchingPanel')).toBeVisible(); - expect(onContentTypeFieldChange).toHaveBeenCalledWith('rule-course', 'enableAdvancedMatching', true); + expect(onContentTypeFieldChange).toHaveBeenCalledWith( + 'rule-course', + 'enableAdvancedMatching', + true + ); }); it('shows advanced matching controls when a row is already configured for them', () => { diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx index 253d56202a..0a60bed2ef 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx @@ -32,7 +32,11 @@ interface Props { allContentTypeEntries: AllContentTypeEntries; contentTypeRules: ContentTypeRules; onContentTypeChange: (ruleId: string, newContentTypeId: string) => void; - onContentTypeFieldChange: (ruleId: string, field: string, value: string | boolean | string[]) => void; + onContentTypeFieldChange: ( + ruleId: string, + field: string, + value: string | boolean | string[] + ) => void; onRemoveContentType: (ruleId: string) => void; currentEditorInterface: Partial; originalContentTypeRules: ContentTypeRules; @@ -161,7 +165,7 @@ const AssignContentTypeRow = (props: Props) => { name={`contentType-${index}`} testId="contentTypeSelect" isInvalid={!isContentTypeInOptions && contentTypeId !== ''} - onChange={(event: React.ChangeEvent) => + onChange={(event: React.ChangeEvent) => onContentTypeChange(ruleId, event.target.value) } value={validateSelectedOption(contentTypeId)}> @@ -181,10 +185,10 @@ const AssignContentTypeRow = (props: Props) => { name={`slugField-${index}`} testId="slugFieldSelect" isDisabled={!contentTypeId || !isContentTypeInOptions} - onChange={(event: React.ChangeEvent) => - onContentTypeFieldChange(ruleId, 'slugField', event.target.value) - } - value={validateSelectedOption(contentTypeId, slugField)}> + onChange={(event: React.ChangeEvent) => + onContentTypeFieldChange(ruleId, 'slugField', event.target.value) + } + value={validateSelectedOption(contentTypeId, slugField)}> Select slug field @@ -193,7 +197,7 @@ const AssignContentTypeRow = (props: Props) => { {field.name} - ))} + ))} @@ -254,21 +258,29 @@ const AssignContentTypeRow = (props: Props) => { const checked = event.target.checked; const nextSelectedFields = checked ? [...additionalFieldIds, field.id] - : additionalFieldIds.filter((selectedFieldId) => selectedFieldId !== field.id); + : additionalFieldIds.filter( + (selectedFieldId) => selectedFieldId !== field.id + ); - onContentTypeFieldChange(ruleId, 'additionalFieldIds', nextSelectedFields); + onContentTypeFieldChange( + ruleId, + 'additionalFieldIds', + nextSelectedFields + ); }}> {field.name} )) ) : ( - No extra fields available for this content type. + + No extra fields available for this content type. + )} - - Select any extra fields you want to reference in the pattern. - - + + Select any extra fields you want to reference in the pattern. + + @@ -285,7 +297,8 @@ const AssignContentTypeRow = (props: Props) => { value={pathPattern} /> - Use {'{slug}'} plus any selected property tokens. Example: /{'{sectionSlug}'}/{'{slug}'} + Use {'{slug}'} plus any selected property tokens. Example: /{'{sectionSlug}'}/ + {'{slug}'} diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx index 25233c31e0..6f97e7ed39 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx @@ -51,7 +51,9 @@ const AssignContentTypeSection = (props: Props) => { const [forceTrailingSlash, setForceTrailingSlash] = useState(false); // Content type rules state - const [contentTypeRules, setContentTypeRules] = useState([] as ContentTypeRules); + const [contentTypeRules, setContentTypeRules] = useState( + [] as ContentTypeRules + ); const [loadingContentTypes, setLoadingContentTypes] = useState(true); const [hasContentTypes, setHasContentTypes] = useState(false); const [hasIncompleteContentTypes, setHasIncompleteContentTypes] = useState(false); @@ -79,9 +81,7 @@ const AssignContentTypeSection = (props: Props) => { useEffect(() => { setHasContentTypes(contentTypeRules.length > 0); - setHasIncompleteContentTypes( - contentTypeRules.some((rule) => !rule.contentTypeId) - ); + setHasIncompleteContentTypes(contentTypeRules.some((rule) => !rule.contentTypeId)); }, [contentTypeRules]); const fetchAllContentTypes = async (sdk: KnownAppSDK): Promise => { diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx index 9f90757ec4..143ffb858c 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx @@ -30,7 +30,9 @@ const renderAnalyticsApp = async () => ); @@ -110,7 +112,12 @@ describe('AnalyticsApp when content types are not configured correctly', () => { validRules: [], summaryLabel: '', isContentTypeWarning: true, - warningRule: { id: 'rule-title', contentTypeId: 'category', slugField: 'slug', urlPrefix: '' }, + warningRule: { + id: 'rule-title', + contentTypeId: 'category', + slugField: 'slug', + urlPrefix: '', + }, })); mockApi.mockImplementation(() => runReportResponseHasViews); const warningMessage = getContentTypeSpecificMsg('Category') diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx index a25133bd10..d9ba2ea10c 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.tsx @@ -21,7 +21,9 @@ interface Props { api: Api; propertyId: string; slugFieldRules: ContentTypeRule[]; - openCustomRangeDialog: (startEndDates: StartEndDates) => Promise; + openCustomRangeDialog: ( + startEndDates: StartEndDates + ) => Promise; } const mergeRunReportResponses = (responses: RunReportData[]): RunReportData => { @@ -160,12 +162,12 @@ const AnalyticsApp = (props: Props) => { } return ( - handleDateRangeChange('custom')} - runReportResponse={runReportResponse} - metricName={metricName} + handleDateRangeChange('custom')} + runReportResponse={runReportResponse} + metricName={metricName} reportSlug={summaryLabel} includedPaths={validRules.map((rule) => rule.reportSlug)} canOpenInGoogleAnalytics={validRules.length === 1} diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx index 29ba14ea1a..bd3178651d 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartFooter/ChartFooter.tsx @@ -15,7 +15,11 @@ const ChartFooter = (props: Props) => { {isAggregated ? ( <> - + Included paths ({includedPaths.length}) diff --git a/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx b/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx index 2b42b4273e..f0ded0fe5d 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/ChartHeader/ChartHeader.tsx @@ -1,5 +1,13 @@ import { useState, useEffect } from 'react'; -import { Box, Button, DisplayText, Flex, Paragraph, Select, Text } from '@contentful/f36-components'; +import { + Box, + Button, + DisplayText, + Flex, + Paragraph, + Select, + Text, +} from '@contentful/f36-components'; import { AnalyticsMetricType, DateRangeType, StartEndDates } from 'types'; import { DATE_RANGE_SELECT_OPTIONS, DateRange } from 'helpers/DateRangeHelpers/DateRangeHelpers'; import { styles } from './ChartHeader.styles'; @@ -101,7 +109,9 @@ const ChartHeader = (props: Props) => { {startEndDates.start} to {startEndDates.end} diff --git a/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts b/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts index 6527c1054c..08816ab418 100644 --- a/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts +++ b/apps/google-analytics-4/frontend/src/helpers/contentTypeRules/contentTypeRules.ts @@ -57,5 +57,7 @@ export const normalizeContentTypeRules = ( export const getUniqueContentTypeIds = (contentTypeRules: ContentTypeRules) => Array.from( - new Set(contentTypeRules.map((rule) => rule.contentTypeId).filter((contentTypeId) => contentTypeId)) + new Set( + contentTypeRules.map((rule) => rule.contentTypeId).filter((contentTypeId) => contentTypeId) + ) ); diff --git a/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx b/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx index 0a6ca7715e..16a0e8c9a5 100644 --- a/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx +++ b/apps/google-analytics-4/frontend/src/hooks/useSidebarRules/useSidebarRules.tsx @@ -22,7 +22,9 @@ export const useSidebarRules = (slugFieldRules: ContentTypeRule[]) => { const [isPublished, setIsPublished] = useState(false); const [fieldValues, setFieldValues] = useState>({}); - const [debouncedFieldValues, setDebouncedFieldValues] = useState>({}); + const [debouncedFieldValues, setDebouncedFieldValues] = useState>( + {} + ); const [haveLoadedFieldValues, setHaveLoadedFieldValues] = useState(false); const relevantFieldIds = useMemo( () => @@ -87,8 +89,7 @@ export const useSidebarRules = (slugFieldRules: ContentTypeRule[]) => { const contentTypeHasSlugField = rule.slugField in entryFields; const additionalFieldIds = rule.additionalFieldIds || []; const contentTypeHasAllFields = - contentTypeHasSlugField && - additionalFieldIds.every((fieldId) => fieldId in entryFields); + contentTypeHasSlugField && additionalFieldIds.every((fieldId) => fieldId in entryFields); const tokenValues = { slug: slugFieldValue, ...Object.fromEntries( diff --git a/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx b/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx index 12a19693c2..25930caf27 100644 --- a/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx +++ b/apps/google-analytics-4/frontend/src/hooks/useSidebarSlug/useSidebarSlug.tsx @@ -36,7 +36,11 @@ export const useSidebarSlug = (slugFieldInfo: ContentTypeValue) => { sdk.entry.onSysChanged((sys) => handlePublishedStatus(sys)); }, [sdk.entry]); - const reportSlug = getReportSlug(slugFieldInfo, debouncedSlugFieldValue || '', forceTrailingSlash); + const reportSlug = getReportSlug( + slugFieldInfo, + debouncedSlugFieldValue || '', + forceTrailingSlash + ); const slugFieldIsConfigured = Boolean(slugField); const contentTypeHasSlugField = slugField in sdk.entry.fields; diff --git a/apps/google-analytics-4/frontend/src/locations/Dialog.tsx b/apps/google-analytics-4/frontend/src/locations/Dialog.tsx index b509efc839..4e9506f41d 100644 --- a/apps/google-analytics-4/frontend/src/locations/Dialog.tsx +++ b/apps/google-analytics-4/frontend/src/locations/Dialog.tsx @@ -41,7 +41,11 @@ const Dialog = () => { onSelect={setStartDate} toDate={endDate} inputProps={{ isReadOnly: true }} - popoverProps={{ usePortal: true, placement: 'bottom-start', isAutoalignmentEnabled: true }} + popoverProps={{ + usePortal: true, + placement: 'bottom-start', + isAutoalignmentEnabled: true, + }} /> @@ -52,7 +56,11 @@ const Dialog = () => { fromDate={startDate} toDate={new Date()} inputProps={{ isReadOnly: true }} - popoverProps={{ usePortal: true, placement: 'bottom-end', isAutoalignmentEnabled: true }} + popoverProps={{ + usePortal: true, + placement: 'bottom-end', + isAutoalignmentEnabled: true, + }} /> diff --git a/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts b/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts index 3e82f526b1..61af25abcd 100644 --- a/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts +++ b/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts @@ -8,8 +8,7 @@ const TOKEN_REGEX = /\{([^}]+)\}/g; type FieldValueMap = Record; const getPatternValue = (token: string, fieldValues: FieldValueMap) => { - const normalizedFieldValue = - token === 'slug' ? fieldValues.slug : fieldValues[token]; + const normalizedFieldValue = token === 'slug' ? fieldValues.slug : fieldValues[token]; return pathJoin(normalizedFieldValue); }; @@ -28,9 +27,10 @@ export const getReportSlug = ( typeof slugFieldValue === 'object' && !Array.isArray(slugFieldValue) ? ({ slug: '', ...slugFieldValue } as FieldValueMap) : ({ slug: slugFieldValue } as FieldValueMap); - const basePath = hasAdvancedMatchingConfigured(contentTypeValue) && pathPattern?.trim() - ? normalizePattern(pathPattern, fieldValues) - : pathJoin(urlPrefix || '', fieldValues.slug || ''); + const basePath = + hasAdvancedMatchingConfigured(contentTypeValue) && pathPattern?.trim() + ? normalizePattern(pathPattern, fieldValues) + : pathJoin(urlPrefix || '', fieldValues.slug || ''); return `/${pathJoin(basePath)}${forceTrailingSlash ? '/' : ''}`; }; diff --git a/apps/google-analytics-4/lambda/src/controllers/apiController.ts b/apps/google-analytics-4/lambda/src/controllers/apiController.ts index 95222a1252..55c2d6284c 100644 --- a/apps/google-analytics-4/lambda/src/controllers/apiController.ts +++ b/apps/google-analytics-4/lambda/src/controllers/apiController.ts @@ -66,8 +66,16 @@ const ApiController = { try { const serviceAccountKey = requireServiceAccountKey(req.serviceAccountKey); - const { propertyId, slug, matchDimension, matchType, startDate, endDate, dimensions, metrics } = - req.query as unknown as RunReportParamsType; + const { + propertyId, + slug, + matchDimension, + matchType, + startDate, + endDate, + dimensions, + metrics, + } = req.query as unknown as RunReportParamsType; const googleApi = GoogleApiService.fromServiceAccountKeyFile(serviceAccountKey); const result = dimensions && metrics From bd39fa7304956ac1d58ea238ff0c0b91dbbd7e96 Mon Sep 17 00:00:00 2001 From: zachary Date: Thu, 16 Apr 2026 10:03:28 -0600 Subject: [PATCH 3/8] Fix GA4 lambda verify-config in CI --- apps/google-analytics-4/lambda/package.json | 2 +- .../lambda/scripts/verify-config.cjs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 apps/google-analytics-4/lambda/scripts/verify-config.cjs diff --git a/apps/google-analytics-4/lambda/package.json b/apps/google-analytics-4/lambda/package.json index 8adf260f36..fae37d0933 100644 --- a/apps/google-analytics-4/lambda/package.json +++ b/apps/google-analytics-4/lambda/package.json @@ -15,7 +15,7 @@ "test:watch": "NODE_ENV=test TS_NODE_TRANSPILE_ONLY=1 mocha --watch --watch-files src --watch-files test -r dotenv/config", "deploy:test": "NODE_ENV=test sls deploy --stage test", "deploy": "NODE_ENV=production CIRCLE_SHA1=$(git rev-parse --short HEAD) sls deploy --stage $STAGE", - "verify-config": "STAGE=$(test \"$CIRCLE_BRANCH\" = 'master' && echo 'prd' || echo 'test'); sls print --stage $STAGE" + "verify-config": "node ./scripts/verify-config.cjs" }, "keywords": [], "author": "", diff --git a/apps/google-analytics-4/lambda/scripts/verify-config.cjs b/apps/google-analytics-4/lambda/scripts/verify-config.cjs new file mode 100644 index 0000000000..a02f59bf99 --- /dev/null +++ b/apps/google-analytics-4/lambda/scripts/verify-config.cjs @@ -0,0 +1,29 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const rootDir = path.resolve(__dirname, '..'); +const sourceConfigPath = path.join(rootDir, 'serverless.yml'); +const tempConfigPath = path.join(rootDir, '.serverless.verify-config.yml'); + +const stage = process.env.CIRCLE_BRANCH === 'master' ? 'prd' : 'test'; + +const sourceConfig = fs.readFileSync(sourceConfigPath, 'utf8'); + +const sanitizedConfig = sourceConfig.replace(/\n - serverless-offline(?=\n)/, ''); + +fs.writeFileSync(tempConfigPath, sanitizedConfig); + +try { + execFileSync( + process.platform === 'win32' ? 'npx.cmd' : 'npx', + ['serverless', 'print', '--config', tempConfigPath, '--stage', stage], + { + cwd: rootDir, + stdio: 'inherit', + env: process.env, + } + ); +} finally { + fs.rmSync(tempConfigPath, { force: true }); +} From 934abcdb59284db3daeea7c39477e7291aa4b684 Mon Sep 17 00:00:00 2001 From: zachary Date: Thu, 16 Apr 2026 10:26:05 -0600 Subject: [PATCH 4/8] Fix GA4 sidebar hydration test expectations --- .../src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx index 143ffb858c..0a559d9a74 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx @@ -57,6 +57,7 @@ describe('AnalyticsApp with correct content types configured', () => { summaryLabel: 'report slug', isContentTypeWarning: false, warningRule: undefined, + haveLoadedFieldValues: true, })); }); @@ -112,6 +113,7 @@ describe('AnalyticsApp when content types are not configured correctly', () => { validRules: [], summaryLabel: '', isContentTypeWarning: true, + haveLoadedFieldValues: true, warningRule: { id: 'rule-title', contentTypeId: 'category', From c8074e30d773535088f1b5d71d04fff2088e15d8 Mon Sep 17 00:00:00 2001 From: zachary Date: Thu, 16 Apr 2026 12:13:16 -0600 Subject: [PATCH 5/8] Reduce GA4 frontend test concurrency in CI --- apps/google-analytics-4/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/google-analytics-4/frontend/package.json b/apps/google-analytics-4/frontend/package.json index 71b577cb52..33bca2c79b 100644 --- a/apps/google-analytics-4/frontend/package.json +++ b/apps/google-analytics-4/frontend/package.json @@ -26,7 +26,7 @@ "start": "cross-env BROWSER=none vite", "build": "VITE_RELEASE=$(git rev-parse --short HEAD) vite build", "test": "vitest --watch", - "test:ci": "CI=true vitest", + "test:ci": "CI=true vitest --minWorkers=1 --maxWorkers=1", "lint": "eslint", "eject": "vite eject", "create-app-definition": "contentful-app-scripts create-app-definition", From 066611be5eedef2550ef48d7e8860f3bc5caa4b1 Mon Sep 17 00:00:00 2001 From: zachary Date: Thu, 16 Apr 2026 15:13:40 -0600 Subject: [PATCH 6/8] Increase GA4 frontend test heap in CI --- apps/google-analytics-4/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/google-analytics-4/frontend/package.json b/apps/google-analytics-4/frontend/package.json index 33bca2c79b..d3460a96ad 100644 --- a/apps/google-analytics-4/frontend/package.json +++ b/apps/google-analytics-4/frontend/package.json @@ -26,7 +26,7 @@ "start": "cross-env BROWSER=none vite", "build": "VITE_RELEASE=$(git rev-parse --short HEAD) vite build", "test": "vitest --watch", - "test:ci": "CI=true vitest --minWorkers=1 --maxWorkers=1", + "test:ci": "NODE_OPTIONS=--max-old-space-size=4096 CI=true vitest --minWorkers=1 --maxWorkers=1", "lint": "eslint", "eject": "vite eject", "create-app-definition": "contentful-app-scripts create-app-definition", From 5730e80328dc46b9b9e7a89ba3ff53ebdcb951ab Mon Sep 17 00:00:00 2001 From: zachary Date: Thu, 16 Apr 2026 15:53:04 -0600 Subject: [PATCH 7/8] Stabilize GA4 frontend CircleCI tests --- apps/google-analytics-4/frontend/package.json | 2 +- .../frontend/scripts/run-vitest-ci.cjs | 74 +++++++++++++++ .../DisplayServiceAccountCard.spec.tsx | 18 ++-- .../AnalyticsApp/AnalyticsApp.spec.tsx | 89 ++++++++++++------- 4 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 apps/google-analytics-4/frontend/scripts/run-vitest-ci.cjs diff --git a/apps/google-analytics-4/frontend/package.json b/apps/google-analytics-4/frontend/package.json index d3460a96ad..ef9b7a2ade 100644 --- a/apps/google-analytics-4/frontend/package.json +++ b/apps/google-analytics-4/frontend/package.json @@ -26,7 +26,7 @@ "start": "cross-env BROWSER=none vite", "build": "VITE_RELEASE=$(git rev-parse --short HEAD) vite build", "test": "vitest --watch", - "test:ci": "NODE_OPTIONS=--max-old-space-size=4096 CI=true vitest --minWorkers=1 --maxWorkers=1", + "test:ci": "node ./scripts/run-vitest-ci.cjs", "lint": "eslint", "eject": "vite eject", "create-app-definition": "contentful-app-scripts create-app-definition", diff --git a/apps/google-analytics-4/frontend/scripts/run-vitest-ci.cjs b/apps/google-analytics-4/frontend/scripts/run-vitest-ci.cjs new file mode 100644 index 0000000000..451499a0ae --- /dev/null +++ b/apps/google-analytics-4/frontend/scripts/run-vitest-ci.cjs @@ -0,0 +1,74 @@ +const { readdirSync, statSync } = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const projectRoot = path.resolve(__dirname, '..'); +const srcRoot = path.join(projectRoot, 'src'); +const vitestEntrypoint = path.join(projectRoot, 'node_modules', 'vitest', 'vitest.mjs'); +const batchSize = Number(process.env.VITEST_BATCH_SIZE || 8); + +function collectSpecFiles(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const absolutePath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...collectSpecFiles(absolutePath)); + continue; + } + + if (entry.isFile() && /\.(spec)\.(ts|tsx)$/.test(entry.name)) { + files.push(path.relative(projectRoot, absolutePath)); + } + } + + return files; +} + +function chunk(list, size) { + const chunks = []; + + for (let index = 0; index < list.length; index += size) { + chunks.push(list.slice(index, index + size)); + } + + return chunks; +} + +const specFiles = collectSpecFiles(srcRoot).sort(); +const batches = chunk(specFiles, batchSize); + +if (specFiles.length === 0) { + console.error('No Vitest spec files were found.'); + process.exit(1); +} + +for (const [index, batch] of batches.entries()) { + console.log(`\nRunning Vitest batch ${index + 1}/${batches.length}`); + + const result = spawnSync( + process.execPath, + [ + vitestEntrypoint, + 'run', + '--minWorkers=1', + '--maxWorkers=1', + ...batch, + ], + { + cwd: projectRoot, + env: { + ...process.env, + CI: 'true', + NODE_OPTIONS: '--max-old-space-size=4096', + }, + stdio: 'inherit', + } + ); + + if (result.status !== 0) { + process.exit(result.status || 1); + } +} diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/api-access/display/DisplayServiceAccountCard.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/api-access/display/DisplayServiceAccountCard.spec.tsx index 6cd497a092..1f6ed16014 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/api-access/display/DisplayServiceAccountCard.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/api-access/display/DisplayServiceAccountCard.spec.tsx @@ -1,22 +1,26 @@ import { render, screen } from '@testing-library/react'; import { mockSdk, mockCma, validServiceKeyId } from '../../../../../test/mocks'; import DisplayServiceAccountCard from 'components/config-screen/api-access/display/DisplayServiceAccountCard'; -import { config } from '../../../../../src/config'; import { vi } from 'vitest'; - -const apiRoot = config.backendApiUrl; +const listAccountSummaries = vi.fn().mockResolvedValue([]); +const runReports = vi.fn().mockResolvedValue({}); vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, useCMA: () => mockCma, })); -export const apiPath = (path: string) => { - return new URL(path, apiRoot).toString(); -}; +vi.mock('hooks/useApi', () => ({ + useApi: () => ({ + listAccountSummaries, + runReports, + }), +})); describe('Installed Service Account Card', () => { beforeEach(() => { + listAccountSummaries.mockClear(); + runReports.mockClear(); mockSdk.app.getParameters.mockReturnValue({ serviceAccountKeyId: validServiceKeyId, }); @@ -25,7 +29,7 @@ describe('Installed Service Account Card', () => { it('is the active happy path', () => { render( {}} diff --git a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx index 0a559d9a74..67a6a971db 100644 --- a/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/main-app/AnalyticsApp/AnalyticsApp.spec.tsx @@ -20,12 +20,29 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ const mockApi = vi.fn(); -const { findByTestId, getByTestId, getByText, queryByTestId } = screen; +const { findAllByTestId, getByTestId, getByText, queryByTestId } = screen; const SELECT_TEST_ID = 'cf-ui-select'; const NOTE_TEST_ID = 'cf-ui-note'; - -const renderAnalyticsApp = async () => +const stableValidRules = [ + { + id: 'rule-title', + contentTypeId: 'category', + slugField: 'title', + urlPrefix: '', + reportSlug: 'report slug', + enableAdvancedMatching: false, + }, +]; +const stableSidebarRulesState = { + validRules: stableValidRules, + summaryLabel: 'report slug', + isContentTypeWarning: false, + warningRule: undefined, + haveLoadedFieldValues: true, +}; + +const renderAnalyticsApp = () => render( describe('AnalyticsApp with correct content types configured', () => { beforeEach(() => { + mockApi.mockReset(); mockSdk.app.getParameters.mockReturnValue({ serviceAccountKeyId: validServiceKeyId, }); + mockSdk.entry = { + onSysChanged: vi.fn((handler) => { + handler({ publishedAt: '2026-04-16T00:00:00.000Z' }); + return vi.fn(); + }), + fields: { + title: {}, + slug: {}, + }, + }; - vi.spyOn(useSidebarRules, 'useSidebarRules').mockImplementation(() => ({ - validRules: [ - { - id: 'rule-title', - contentTypeId: 'category', - slugField: 'title', - urlPrefix: '', - reportSlug: 'report slug', - enableAdvancedMatching: false, - }, - ], - summaryLabel: 'report slug', - isContentTypeWarning: false, - warningRule: undefined, - haveLoadedFieldValues: true, - })); + vi.spyOn(useSidebarRules, 'useSidebarRules').mockImplementation(() => stableSidebarRulesState); }); it('mounts data', async () => { mockApi.mockImplementation(() => runReportResponseHasViews); renderAnalyticsApp(); - const dropdown = await findByTestId(SELECT_TEST_ID); + const dropdowns = await findAllByTestId(SELECT_TEST_ID); const chart = document.querySelector('canvas'); - expect(dropdown).toBeVisible(); + expect(dropdowns).toHaveLength(2); expect(chart).toBeVisible(); }); @@ -76,24 +89,24 @@ describe('AnalyticsApp with correct content types configured', () => { mockApi.mockImplementation(() => runReportResponseNoView); renderAnalyticsApp(); - const dropdown = await findByTestId(SELECT_TEST_ID); + const dropdowns = await findAllByTestId(SELECT_TEST_ID); const warningNote = getByTestId(NOTE_TEST_ID); const noteText = getByText(EMPTY_DATA_MSG); - expect(dropdown).toBeVisible(); + expect(dropdowns).toHaveLength(2); expect(warningNote).toBeVisible(); expect(noteText).toBeVisible(); }); it('mounts with error message when error thrown', async () => { - mockApi.mockRejectedValue(() => new Error('api error')); + mockApi.mockRejectedValue(new Error('api error')); renderAnalyticsApp(); - const dropdown = await findByTestId(SELECT_TEST_ID); + const dropdowns = await findAllByTestId(SELECT_TEST_ID); const warningNote = getByTestId(NOTE_TEST_ID); const noteText = getByText('api error'); - expect(dropdown).toBeVisible(); + expect(dropdowns).toHaveLength(2); expect(warningNote).toBeVisible(); expect(noteText).toBeVisible(); }); @@ -109,17 +122,27 @@ describe('AnalyticsApp with correct content types configured', () => { describe('AnalyticsApp when content types are not configured correctly', () => { it('renders SlugWarningDisplay component when slug field is not configured', async () => { + mockSdk.entry = { + onSysChanged: vi.fn((handler) => { + handler({ publishedAt: '2026-04-16T00:00:00.000Z' }); + return vi.fn(); + }), + fields: { + title: {}, + }, + }; + const warningRule = { + id: 'rule-title', + contentTypeId: 'category', + slugField: 'slug', + urlPrefix: '', + }; vi.spyOn(useSidebarRules, 'useSidebarRules').mockImplementation(() => ({ validRules: [], summaryLabel: '', isContentTypeWarning: true, haveLoadedFieldValues: true, - warningRule: { - id: 'rule-title', - contentTypeId: 'category', - slugField: 'slug', - urlPrefix: '', - }, + warningRule, })); mockApi.mockImplementation(() => runReportResponseHasViews); const warningMessage = getContentTypeSpecificMsg('Category') @@ -128,7 +151,7 @@ describe('AnalyticsApp when content types are not configured correctly', () => { renderAnalyticsApp(); const dropdown = queryByTestId(SELECT_TEST_ID); - const warningNote = await findByTestId(NOTE_TEST_ID); + const warningNote = await screen.findByTestId(NOTE_TEST_ID); const noteText = getByText(warningMessage); expect(dropdown).toBeFalsy(); From dbe020294eace94abdc8859d294e751de9103000 Mon Sep 17 00:00:00 2001 From: zachary Date: Mon, 20 Apr 2026 14:37:35 -0600 Subject: [PATCH 8/8] Refine GA4 advanced matching configuration UX --- .../AssignContentType.styles.ts | 22 +- .../AssignContentTypeCard.spec.tsx | 4 + .../AssignContentTypeCard.tsx | 10 +- .../AssignContentTypeRow.spec.tsx | 230 +++++++++++++++- .../AssignContentTypeRow.tsx | 260 ++++++++++++------ .../AssignContentTypeSection.spec.tsx | 73 ++++- .../AssignContentTypeSection.tsx | 33 ++- .../frontend/src/utils/getReportSlug.spec.ts | 35 ++- .../frontend/src/utils/getReportSlug.ts | 31 ++- 9 files changed, 593 insertions(+), 105 deletions(-) diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts index 7f7093db37..0189f7a714 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentType.styles.ts @@ -15,6 +15,13 @@ export const styles = { flexWrap: 'wrap', width: '100%', }), + advancedMatchingGroup: css({ + background: '#F7FAFC', + border: '1px solid #D5DFE5', + borderRadius: '8px', + marginBottom: '16px', + padding: '12px', + }), advancedMatchingTopRow: css({ alignItems: 'flex-start', display: 'flex', @@ -32,10 +39,10 @@ export const styles = { flex: 4, }), advancedMatchingPanel: css({ - background: '#FCFDFD', + background: '#FFFFFF', border: '1px solid #E5EBED', borderRadius: '6px', - marginTop: '4px', + marginTop: '8px', padding: '10px 12px 12px', width: '100%', }), @@ -49,6 +56,17 @@ export const styles = { gap: '12px', width: '100%', }), + baseRowPanel: css({ + background: '#FFFFFF', + border: '1px solid #E5EBED', + borderRadius: '6px', + padding: '12px', + width: '100%', + }), + rowSpacing: css({ + marginBottom: '16px', + width: '100%', + }), removeItem: css({ flex: 1.2, alignItems: 'center', diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx index 80e90ce823..9844d284b8 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.spec.tsx @@ -38,9 +38,11 @@ describe('Assign Content Type Card for Config Screen', () => { contentTypeRules={contentTypeRules} onContentTypeChange={() => {}} onContentTypeFieldChange={() => {}} + onContentTypeRuleChange={() => {}} onRemoveContentType={() => {}} currentEditorInterface={{}} originalContentTypeRules={[]} + rulesMissingPattern={new Set()} /> ); @@ -58,9 +60,11 @@ describe('Assign Content Type Card for Config Screen', () => { contentTypeRules={contentTypeRules} onContentTypeChange={() => {}} onContentTypeFieldChange={() => {}} + onContentTypeRuleChange={() => {}} onRemoveContentType={() => {}} currentEditorInterface={{}} originalContentTypeRules={[]} + rulesMissingPattern={new Set()} /> ); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx index 21fdfa1d01..ac569543ac 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeCard.tsx @@ -2,7 +2,7 @@ import { Box, Card, Flex, FormControl, Tooltip } from '@contentful/f36-component import { HelpCircleIcon } from '@contentful/f36-icons'; import { styles } from 'components/config-screen/assign-content-type/AssignContentType.styles'; import { EditorInterface } from '@contentful/app-sdk'; -import { AllContentTypes, AllContentTypeEntries, ContentTypeRules } from 'types'; +import { AllContentTypes, AllContentTypeEntries, ContentTypeRule, ContentTypeRules } from 'types'; import AssignContentTypeRow from 'components/config-screen/assign-content-type/AssignContentTypeRow'; interface AssignContentTypeCardProps { @@ -15,9 +15,11 @@ interface AssignContentTypeCardProps { field: string, value: string | boolean | string[] ) => void; + onContentTypeRuleChange: (ruleId: string, updates: Partial) => void; onRemoveContentType: (ruleId: string) => void; currentEditorInterface: Partial; originalContentTypeRules: ContentTypeRules; + rulesMissingPattern: Set; } interface HeaderLabelProps { @@ -58,9 +60,11 @@ const AssignContentTypeCard = (props: AssignContentTypeCardProps) => { contentTypeRules, onContentTypeChange, onContentTypeFieldChange, + onContentTypeRuleChange, onRemoveContentType, currentEditorInterface, originalContentTypeRules, + rulesMissingPattern, } = props; return ( @@ -81,7 +85,7 @@ const AssignContentTypeCard = (props: AssignContentTypeCardProps) => { @@ -96,9 +100,11 @@ const AssignContentTypeCard = (props: AssignContentTypeCardProps) => { contentTypeRules={contentTypeRules} onContentTypeChange={onContentTypeChange} onContentTypeFieldChange={onContentTypeFieldChange} + onContentTypeRuleChange={onContentTypeRuleChange} onRemoveContentType={onRemoveContentType} currentEditorInterface={currentEditorInterface} originalContentTypeRules={originalContentTypeRules} + isMissingPattern={rulesMissingPattern.has(contentTypeRule.id)} focus={index + 1 === contentTypeRules.length} /> ); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx index 0406fbc90f..8f1cee2fb5 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.spec.tsx @@ -46,14 +46,17 @@ const contentTypeRules: ContentTypeRules = [ const onRemoveContentType = vi.fn(); const onContentTypeChange = vi.fn(); const onContentTypeFieldChange = vi.fn(); +const onContentTypeRuleChange = vi.fn(); const props = { index: 0, allContentTypes, allContentTypeEntries, contentTypeRules, + isMissingPattern: false, onContentTypeChange, onContentTypeFieldChange, + onContentTypeRuleChange, onRemoveContentType, currentEditorInterface: {}, originalContentTypeRules: [], @@ -147,7 +150,7 @@ describe('Assign Content Type Card for Config Screen', () => { const user = userEvent.setup(); render( ); @@ -155,10 +158,12 @@ describe('Assign Content Type Card for Config Screen', () => { await user.click(screen.getByTestId('advancedMatchingToggle')); expect(screen.getByTestId('advancedMatchingPanel')).toBeVisible(); - expect(onContentTypeFieldChange).toHaveBeenCalledWith( + expect(onContentTypeRuleChange).toHaveBeenCalledWith( 'rule-course', - 'enableAdvancedMatching', - true + { + enableAdvancedMatching: true, + pathPattern: '/about/{slug}', + } ); }); @@ -180,6 +185,38 @@ describe('Assign Content Type Card for Config Screen', () => { expect(screen.queryByTestId('urlPrefixInput')).not.toBeInTheDocument(); }); + it('clears advanced-only fields when advanced matching is turned off', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('advancedMatchingToggle')); + + expect(screen.queryByTestId('advancedMatchingPanel')).not.toBeInTheDocument(); + expect(onContentTypeRuleChange).toHaveBeenCalledWith( + 'rule-course', + { + enableAdvancedMatching: false, + additionalFieldIds: [], + pathPattern: '', + matchDimension: 'unifiedPagePathScreen', + matchType: 'EXACT', + } + ); + }); + it('shows a preview with additional page property tokens when configured', () => { render( { expect(screen.getByTestId('pathPatternInput')).toHaveValue('/{sectionSlug}/{slug}'); }); + it('explains token usage and updated matching terminology in advanced mode', () => { + render( + + ); + + expect( + screen.getByText( + /A pattern is generated for you automatically\. Edit it if you need a different URL structure/ + ) + ).toBeVisible(); + expect( + screen.getByText(/Use the placeholder shown next to each field in the pattern\./) + ).toBeVisible(); + expect(screen.getByText('{sectionSlug}')).toBeVisible(); + expect(screen.getByRole('option', { name: 'Flexible match' })).toBeInTheDocument(); + }); + + it('shows missing pattern validation inside the advanced panel', () => { + render( + + ); + + expect(screen.getByText('Add a pattern for this advanced rule before saving.')).toBeVisible(); + expect(screen.getByTestId('advancedMatchingPanel')).toContainElement( + screen.getByText('Add a pattern for this advanced rule before saving.') + ); + }); + it('calls field change handler when path pattern input is changed', async () => { const user = userEvent.setup(); render( @@ -235,7 +318,144 @@ describe('Assign Content Type Card for Config Screen', () => { 'pagePathPlusQueryString', ]); - expect(onContentTypeFieldChange).toHaveBeenCalled(); + expect(onContentTypeFieldChange).toHaveBeenCalledWith( + 'rule-course', + 'matchDimension', + 'pagePathPlusQueryString' + ); + }); + + it('updates the generated pattern when match dimension changes before customization', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.selectOptions(screen.getByTestId('matchDimensionSelect'), [ + 'pagePathPlusQueryString', + ]); + + expect(onContentTypeRuleChange).toHaveBeenCalledWith('rule-course', { + matchDimension: 'pagePathPlusQueryString', + pathPattern: '/article/{slug}?articleId={articleId}', + }); + }); + + it('updates the generated pattern when selected query-string fields change', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('additionalFieldOption-sectionSlug')); + + expect(onContentTypeRuleChange).toHaveBeenCalledWith( + 'rule-category', + { + additionalFieldIds: ['sectionSlug'], + pathPattern: '/search/{slug}?sectionSlug={sectionSlug}', + } + ); + }); + + it('updates the generated pattern when selected page-path fields change', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('additionalFieldOption-sectionSlug')); + + expect(onContentTypeRuleChange).toHaveBeenCalledWith( + 'rule-category', + { + additionalFieldIds: ['sectionSlug'], + pathPattern: '/{sectionSlug}/{slug}', + } + ); + }); + + it('does not overwrite a custom pattern when selected query-string fields change', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('additionalFieldOption-sectionSlug')); + + expect(onContentTypeFieldChange).toHaveBeenCalledWith( + 'rule-category', + 'additionalFieldIds', + ['sectionSlug'] + ); }); it('calls field change handler when matching mode selection is changed', async () => { diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx index 0a60bed2ef..85664a9505 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeRow.tsx @@ -4,6 +4,7 @@ import { Checkbox, Flex, FormControl, + Note, Select, Stack, Text, @@ -22,7 +23,7 @@ import { ContentTypeValue, } from 'types'; import ContentTypeWarning from 'components/config-screen/assign-content-type/ContentTypeWarning'; -import { pathPatternPreview } from 'utils/getReportSlug'; +import { buildDefaultPathPattern } from 'utils/getReportSlug'; import { hasAdvancedMatchingConfigured } from 'utils/contentTypeMatching'; interface Props { @@ -31,12 +32,14 @@ interface Props { allContentTypes: AllContentTypes; allContentTypeEntries: AllContentTypeEntries; contentTypeRules: ContentTypeRules; + isMissingPattern: boolean; onContentTypeChange: (ruleId: string, newContentTypeId: string) => void; onContentTypeFieldChange: ( ruleId: string, field: string, value: string | boolean | string[] ) => void; + onContentTypeRuleChange: (ruleId: string, updates: Partial) => void; onRemoveContentType: (ruleId: string) => void; currentEditorInterface: Partial; originalContentTypeRules: ContentTypeRules; @@ -50,8 +53,10 @@ const AssignContentTypeRow = (props: Props) => { allContentTypes, allContentTypeEntries, contentTypeRules, + isMissingPattern, onContentTypeChange, onContentTypeFieldChange, + onContentTypeRuleChange, onRemoveContentType, currentEditorInterface, originalContentTypeRules, @@ -138,16 +143,35 @@ const AssignContentTypeRow = (props: Props) => { return value; }; + const resetAdvancedMatching = () => { + onContentTypeRuleChange(ruleId, { + enableAdvancedMatching: false, + additionalFieldIds: [], + pathPattern: '', + matchDimension: 'unifiedPagePathScreen', + matchType: 'EXACT', + }); + }; + const selectableAdditionalFields = contentTypeId && allContentTypes[contentTypeId]?.fields ? allContentTypes[contentTypeId].fields.filter((field) => field.id !== slugField) : []; + const getGeneratedPattern = ( + selectedFieldIds = additionalFieldIds, + selectedMatchDimension = matchDimension + ) => buildDefaultPathPattern(urlPrefix, selectedFieldIds, selectedMatchDimension); + return ( { isContentTypeInOptions={isContentTypeInOptions} isSlugFieldInOptions={isSlugFieldInOptions} /> - - - ) => + onContentTypeChange(ruleId, event.target.value) + } + value={validateSelectedOption(contentTypeId)}> + + Select content type - ))} - - - - - - - {!showAdvancedMatching ? ( - + + + + + + {!showAdvancedMatching ? ( + ) => + onContentTypeFieldChange(ruleId, 'urlPrefix', event.target.value) + } + value={urlPrefix} + /> + ) : ( + Configured below + )} + + + ) => { + const nextIsAdvanced = event.target.checked; + setShowAdvancedMatching(nextIsAdvanced); + if (nextIsAdvanced) { + onContentTypeRuleChange(ruleId, { + enableAdvancedMatching: true, + pathPattern: pathPattern.trim() ? pathPattern : getGeneratedPattern(), + }); + return; + } + + resetAdvancedMatching(); + }}> + Advanced + + + + onRemoveContentType(ruleId)}> + Remove + + + + {showAdvancedMatching && ( + {isMissingPattern && ( + + Add a pattern for this advanced rule before saving. + + )} - Use this for query strings or variable prefixes. + Build a custom URL pattern for query strings, extra path segments, or variable + prefixes. @@ -261,14 +301,25 @@ const AssignContentTypeRow = (props: Props) => { : additionalFieldIds.filter( (selectedFieldId) => selectedFieldId !== field.id ); + const currentGeneratedPattern = getGeneratedPattern(); + const nextGeneratedPattern = getGeneratedPattern(nextSelectedFields); + + if (!pathPattern.trim() || pathPattern === currentGeneratedPattern) { + onContentTypeRuleChange(ruleId, { + additionalFieldIds: nextSelectedFields, + pathPattern: nextGeneratedPattern, + }); + return; + } - onContentTypeFieldChange( - ruleId, - 'additionalFieldIds', - nextSelectedFields - ); + onContentTypeFieldChange(ruleId, 'additionalFieldIds', nextSelectedFields); }}> - {field.name} + + {field.name} + + {`{${field.id}}`} + + )) ) : ( @@ -278,7 +329,8 @@ const AssignContentTypeRow = (props: Props) => { )} - Select any extra fields you want to reference in the pattern. + Select extra fields to include in the URL. Use the placeholder shown next to + each field in the pattern. @@ -297,8 +349,8 @@ const AssignContentTypeRow = (props: Props) => { value={pathPattern} /> - Use {'{slug}'} plus any selected property tokens. Example: /{'{sectionSlug}'}/ - {'{slug}'} + A pattern is generated for you automatically. Edit it if you need a different + URL structure or want to use placeholders from the left. @@ -306,14 +358,42 @@ const AssignContentTypeRow = (props: Props) => { - Match against + + + Match against + + + + + + + diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx index 971de09bdc..78018d68b8 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { mockSdk, mockCma } from '../../../../test/mocks'; import AssignContentTypeSection from 'components/config-screen/assign-content-type/AssignContentTypeSection'; import { vi } from 'vitest'; @@ -27,4 +27,75 @@ describe('Assign Content Type Section for Config Screen', () => { expect(screen.getByText('Content type configuration')).toBeVisible(); expect(screen.getByText('Use trailing slash for all page paths')).toBeVisible(); }); + + it('marks advanced rules without a pattern as invalid', async () => { + const onIsValidContentTypeAssignment = vi.fn(); + + render( + {}} + onIsValidContentTypeAssignment={onIsValidContentTypeAssignment} + parameters={{ + contentTypeRules: [ + { + id: 'rule-1', + contentTypeId: 'searchPage', + slugField: 'slug', + urlPrefix: '', + enableAdvancedMatching: true, + pathPattern: '', + matchDimension: 'pagePathPlusQueryString', + matchType: 'EXACT', + }, + ], + }} + currentEditorInterface={{}} + originalContentTypes={{}} + originalContentTypeRules={[]} + /> + ); + + expect( + await screen.findByText('Add a pattern for this advanced rule before saving.') + ).toBeVisible(); + + await waitFor(() => + expect(onIsValidContentTypeAssignment).toHaveBeenLastCalledWith(false) + ); + }); + + it('keeps trailing slash enabled when any rule uses advanced matching', async () => { + render( + {}} + onIsValidContentTypeAssignment={() => {}} + parameters={{ + forceTrailingSlash: true, + contentTypeRules: [ + { + id: 'rule-1', + contentTypeId: 'searchPage', + slugField: 'slug', + urlPrefix: '', + enableAdvancedMatching: true, + pathPattern: '/search?category={slug}', + matchDimension: 'pagePathPlusQueryString', + matchType: 'EXACT', + }, + ], + }} + currentEditorInterface={{}} + originalContentTypes={{}} + originalContentTypeRules={[]} + /> + ); + + expect( + await screen.findByText( + 'Applies to standard configurations only. Advanced patterns are used exactly as written.' + ) + ).toBeVisible(); + expect(screen.getByLabelText('Use trailing slash for all page paths')).toBeEnabled(); + expect(screen.getByLabelText('Use trailing slash for all page paths')).toBeChecked(); + }); }); diff --git a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx index 6f97e7ed39..4e8cdfbaec 100644 --- a/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx +++ b/apps/google-analytics-4/frontend/src/components/config-screen/assign-content-type/AssignContentTypeSection.tsx @@ -84,6 +84,18 @@ const AssignContentTypeSection = (props: Props) => { setHasIncompleteContentTypes(contentTypeRules.some((rule) => !rule.contentTypeId)); }, [contentTypeRules]); + const rulesMissingPattern = new Set( + contentTypeRules + .filter((rule) => rule.enableAdvancedMatching && rule.contentTypeId && !rule.pathPattern?.trim()) + .map((rule) => rule.id) + ); + + const isContentTypeAssignmentValid = rulesMissingPattern.size === 0; + + useEffect(() => { + onIsValidContentTypeAssignment(isContentTypeAssignmentValid); + }, [isContentTypeAssignmentValid, onIsValidContentTypeAssignment]); + const fetchAllContentTypes = async (sdk: KnownAppSDK): Promise => { const cma = createClient({ apiAdapter: sdk.cmaAdapter }); const space = await cma.getSpace(sdk.ids.space); @@ -124,15 +136,12 @@ const AssignContentTypeSection = (props: Props) => { const trailingSlashHandler = () => { setForceTrailingSlash(!forceTrailingSlash); mergeSdkParameters({ forceTrailingSlash: !forceTrailingSlash }); - onIsValidContentTypeAssignment(true); }; const contentTypeRulesHandler = (newContentTypeRules: ContentTypeRules) => { setContentTypeRules(newContentTypeRules); const _parameters = { contentTypeRules: newContentTypeRules }; mergeSdkParameters(_parameters); - // We always want the user to be able to save the configuration, even if there are errors or warnings - onIsValidContentTypeAssignment(true); }; const handleContentTypeChange = (ruleId: string, newContentTypeId: string) => { @@ -167,6 +176,19 @@ const AssignContentTypeSection = (props: Props) => { contentTypeRulesHandler(newContentTypeRules); }; + const handleContentTypeRuleChange = (ruleId: string, updates: Partial) => { + const newContentTypeRules = contentTypeRules.map((rule) => + rule.id === ruleId + ? { + ...rule, + ...updates, + } + : rule + ); + + contentTypeRulesHandler(newContentTypeRules); + }; + const handleAddContentType = () => { contentTypeRulesHandler([...contentTypeRules, createDefaultRule()]); }; @@ -203,6 +225,9 @@ const AssignContentTypeSection = (props: Props) => { onChange={trailingSlashHandler}> Use trailing slash for all page paths + + Applies to standard configurations only. Advanced patterns are used exactly as written. + {!loadingContentTypes && !loadingAllContentTypes ? ( <> @@ -213,9 +238,11 @@ const AssignContentTypeSection = (props: Props) => { contentTypeRules={contentTypeRules} onContentTypeChange={handleContentTypeChange} onContentTypeFieldChange={handleContentTypeFieldChange} + onContentTypeRuleChange={handleContentTypeRuleChange} onRemoveContentType={handleRemoveContentType} currentEditorInterface={currentEditorInterface} originalContentTypeRules={originalContentTypeRules} + rulesMissingPattern={rulesMissingPattern} /> )} {contentTypeRules.length < Object.keys(allContentTypes).length * 5 && ( diff --git a/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts b/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts index 6fb837d50b..276f2712eb 100644 --- a/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts +++ b/apps/google-analytics-4/frontend/src/utils/getReportSlug.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getReportSlug, pathPatternPreview } from './getReportSlug'; +import { buildDefaultPathPattern, getReportSlug, pathPatternPreview } from './getReportSlug'; describe('getReportSlug', () => { it('supports advanced patterns with multiple field tokens', () => { @@ -41,9 +41,42 @@ describe('getReportSlug', () => { expect(reportSlug).toBe('/north-america/denver/luxury-homes/'); }); + it('does not append a trailing slash after query string patterns', () => { + const reportSlug = getReportSlug( + { + slugField: 'slug', + urlPrefix: '', + enableAdvancedMatching: true, + pathPattern: '/search/?category={slug}', + }, + { + slug: 'platform', + }, + true + ); + + expect(reportSlug).toBe('/search/?category=platform'); + }); + it('builds previews for additional field tokens', () => { expect(pathPatternPreview('/{sectionSlug}/{slug}', ['sectionSlug'])).toBe( '/example-sectionSlug/example-slug' ); }); + + it('builds a default page-path pattern from the existing url prefix', () => { + expect(buildDefaultPathPattern('/blog/')).toBe('/blog/{slug}'); + }); + + it('builds a default page-path pattern with selected path segments', () => { + expect(buildDefaultPathPattern('', ['regionSlug', 'citySlug'])).toBe( + '/{regionSlug}/{citySlug}/{slug}' + ); + }); + + it('builds a default query-string pattern when extra fields are selected', () => { + expect(buildDefaultPathPattern('/search', ['category'], 'pagePathPlusQueryString')).toBe( + '/search/{slug}?category={category}' + ); + }); }); diff --git a/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts b/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts index 61af25abcd..1a67b137fb 100644 --- a/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts +++ b/apps/google-analytics-4/frontend/src/utils/getReportSlug.ts @@ -17,6 +17,35 @@ const normalizePattern = (pathPattern: string, fieldValues: FieldValueMap) => { return pathPattern.replace(TOKEN_REGEX, (_match, token) => getPatternValue(token, fieldValues)); }; +const applyTrailingSlash = (path: string, forceTrailingSlash: boolean) => { + if (!forceTrailingSlash || path.includes('?')) { + return path; + } + + return path.endsWith('/') ? path : `${path}/`; +}; + +export const buildDefaultPathPattern = ( + urlPrefix = '', + additionalFieldIds: string[] = [], + matchDimension: ContentTypeValue['matchDimension'] = 'unifiedPagePathScreen' +) => { + const basePath = + matchDimension === 'pagePathPlusQueryString' + ? `/${pathJoin(urlPrefix, SLUG_TOKEN)}` + : `/${pathJoin(urlPrefix, ...additionalFieldIds.map((fieldId) => `{${fieldId}}`), SLUG_TOKEN)}`; + + if (matchDimension !== 'pagePathPlusQueryString' || additionalFieldIds.length === 0) { + return basePath; + } + + const queryString = additionalFieldIds + .map((fieldId) => `${fieldId}={${fieldId}}`) + .join('&'); + + return `${basePath}?${queryString}`; +}; + export const getReportSlug = ( contentTypeValue: ContentTypeValue, slugFieldValue: string | object, @@ -32,7 +61,7 @@ export const getReportSlug = ( ? normalizePattern(pathPattern, fieldValues) : pathJoin(urlPrefix || '', fieldValues.slug || ''); - return `/${pathJoin(basePath)}${forceTrailingSlash ? '/' : ''}`; + return `/${applyTrailingSlash(pathJoin(basePath), forceTrailingSlash)}`; }; export const pathPatternPreview = (pathPattern: string, additionalFieldIds: string[] = []) => {