diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3259502d9b..64b2a9a85e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,14 @@ - + **Issue**: ### What I did - + - - ### How to test - + - - @@ -19,13 +19,13 @@ #### For all PR's -- [ ] I've added relevant changes to the project Readme if needed. -- [ ] I've updated the documentation site as needed. -- [ ] I have added unit tests to cover my changes (if not applicable, please state why in a comment) +- [ ] I've added relevant changes to the project Readme if needed +- [ ] I've updated the documentation site as needed +- [ ] I have added tests to cover my changes (if not applicable, please state why in a comment) -#### For new components only +#### For new UI components only -- [ ] I have added a story covering all variants +- [ ] I have added a storybook story covering all variants - [ ] I have checked a11y tab in storybook passes -- [ ] Any events are emitted on the event bus - +- [ ] I have added cypress component tests (if the component is interactive) +- [ ] Any events are emitted on the event bus using `emitRplEvent` diff --git a/CHANGELOG.md b/CHANGELOG.md index 622bdd96b8..011b3c9b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## v2.17.0 + +[compare changes](https://github.com/dpc-sdp/ripple-framework/compare/2.16.0...v2.17.0) + + +### 🚀 Enhancements + + - **@dpc-sdp/ripple-tide-webform:** Added support for CAPTCHAs in webforms ([611794fcf](https://github.com/dpc-sdp/ripple-framework/commit/611794fcf)) + - **@dpc-sdp/ripple-tide-webform:** Updated captcha mapping after backend update ([15ddd5d41](https://github.com/dpc-sdp/ripple-framework/commit/15ddd5d41)) + - **@dpc-sdp/ripple-tide-search:** Set pii value for search listing filters ([383195090](https://github.com/dpc-sdp/ripple-framework/commit/383195090)) + - **@dpc-sdp/ripple-ui-forms:** Use labels for dataLayer events, add rpl-form-number dataLayer event ([ac8d207af](https://github.com/dpc-sdp/ripple-framework/commit/ac8d207af)) + - **@dpc-sdp/ripple-tide-webform:** Captchas for content rating and support multiple on a page ([30a0f90a6](https://github.com/dpc-sdp/ripple-framework/commit/30a0f90a6)) + - **@dpc-sdp/ripple-ui-core:** Add aria label to vertical nav toggles ([edda55e8e](https://github.com/dpc-sdp/ripple-framework/commit/edda55e8e)) + - **@dpc-sdp/nuxt-ripple-analytics:** Add content status to routeChange event ([91d408c32](https://github.com/dpc-sdp/ripple-framework/commit/91d408c32)) + - **@dpc-sdp/ripple-ui-core:** Add back to top event ([e9a6148c2](https://github.com/dpc-sdp/ripple-framework/commit/e9a6148c2)) + - **@dpc-sdp/ripple-tide-webform:** Change the way captcha is configured to be less finnicky ([fa53a4719](https://github.com/dpc-sdp/ripple-framework/commit/fa53a4719)) + - **@dpc-sdp/ripple-tide-publication:** Extend page links event ([2fc97251a](https://github.com/dpc-sdp/ripple-framework/commit/2fc97251a)) + - **@dpc-sdp/ripple-ui-forms:** Added form_start analytics event ([9f4a26c9e](https://github.com/dpc-sdp/ripple-framework/commit/9f4a26c9e)) + - **@dpc-sdp/ripple-tide-webform:** Handle recaptcha v2 quota exceeding gracefully ([345bdee4a](https://github.com/dpc-sdp/ripple-framework/commit/345bdee4a)) + - **@dpc-sdp/ripple-tide-publication:** Add count and index to page link event ([cdc702e40](https://github.com/dpc-sdp/ripple-framework/commit/cdc702e40)) + - **@dpc-sdp/ripple-tide-webform:** Logged captcha success with form id ([2ecb481bb](https://github.com/dpc-sdp/ripple-framework/commit/2ecb481bb)) + - **@dpc-sdp/ripple-tide-webform:** Only load captcha scripts when captcha element is rendered ([a66b17710](https://github.com/dpc-sdp/ripple-framework/commit/a66b17710)) + - **@dpc-sdp/ripple-tide-search:** Add prop for map height to match rpl-map ([372885a74](https://github.com/dpc-sdp/ripple-framework/commit/372885a74)) + - **@dpc-sdp/ripple-ui-forms:** Added form abandon analytics event ([d4dc8c398](https://github.com/dpc-sdp/ripple-framework/commit/d4dc8c398)) + +### 🩹 Fixes + + - **@dpc-sdp/ripple-tide-webform:** Fixed rollup import error ([18d259672](https://github.com/dpc-sdp/ripple-framework/commit/18d259672)) + - **@dpc-sdp/ripple-ui-forms:** Fixed issue with util imports in storybook ([19754e807](https://github.com/dpc-sdp/ripple-framework/commit/19754e807)) + - **@dpc-sdp/ripple-ui-forms:** Fixed storybook tests ([b61548848](https://github.com/dpc-sdp/ripple-framework/commit/b61548848)) + - **@dpc-sdp/ripple-ui-core:** Update download attribute ([335e36792](https://github.com/dpc-sdp/ripple-framework/commit/335e36792)) + - **@dpc-sdp/ripple-tide-webform:** Fixed broken test after content rating form changes ([909f94402](https://github.com/dpc-sdp/ripple-framework/commit/909f94402)) + - **@dpc-sdp/ripple-ui-core:** Remove un-needed tabindex from vertical nav ([ca11280f8](https://github.com/dpc-sdp/ripple-framework/commit/ca11280f8)) + - **@dpc-sdp/ripple-ui-core:** 🐛 remove double render ([0466cf5c0](https://github.com/dpc-sdp/ripple-framework/commit/0466cf5c0)) + - **@dpc-sdp/ripple-ui-core:** Update tabs story so tabs can be clicked ([8f8b2f8ae](https://github.com/dpc-sdp/ripple-framework/commit/8f8b2f8ae)) + - **@dpc-sdp/ripple-tide-api:** Update getLinkFromField to account for internal: links ([2e23ba81f](https://github.com/dpc-sdp/ripple-framework/commit/2e23ba81f)) + - **@dpc-sdp/ripple-ui-forms:** Fixed missing import for rplform ([e749444ca](https://github.com/dpc-sdp/ripple-framework/commit/e749444ca)) + - **@dpc-sdp/ripple-tide-search:** Add full stop to the end of sentence ([da648273d](https://github.com/dpc-sdp/ripple-framework/commit/da648273d)) + +### 💅 Refactors + + - **@dpc-sdp/ripple-ui-core:** ♻️ rework breadcrumbs for 4+ terms ([c14334320](https://github.com/dpc-sdp/ripple-framework/commit/c14334320)) + - **@dpc-sdp/ripple-ui-core:** ♻️ support slot usage ([88c6274fe](https://github.com/dpc-sdp/ripple-framework/commit/88c6274fe)) + - **@dpc-sdp/ripple-ui-core:** ♻️ use a single collector index ([621ea1a9e](https://github.com/dpc-sdp/ripple-framework/commit/621ea1a9e)) + - **@dpc-sdp/ripple-ui-core:** ♻️ improve accessibility ([94f38f122](https://github.com/dpc-sdp/ripple-framework/commit/94f38f122)) + - **@dpc-sdp/ripple-ui-forms:** Use input instead of change event for form_start event ([2cc23d281](https://github.com/dpc-sdp/ripple-framework/commit/2cc23d281)) + - **@dpc-sdp/ripple-ui-core:** ♻️ use prop and featureflag ([14f31aa0d](https://github.com/dpc-sdp/ripple-framework/commit/14f31aa0d)) + +### 📖 Documentation + + - **@dpc-sdp/ripple-ui-core:** 📝 add markup to story ([220219c52](https://github.com/dpc-sdp/ripple-framework/commit/220219c52)) + +### 🏡 Chore + + - Update PR template ([f16fa689d](https://github.com/dpc-sdp/ripple-framework/commit/f16fa689d)) + - ⬆️ update nuxt to 3.13.2 ([60830c6b2](https://github.com/dpc-sdp/ripple-framework/commit/60830c6b2)) + - ⬆️ fix formkit version and typescript error on test utils ([0db813243](https://github.com/dpc-sdp/ripple-framework/commit/0db813243)) + +### 🎨 Styles + + - **@dpc-sdp/ripple-tide-grant:** Add space between grant downloads ([b8decf191](https://github.com/dpc-sdp/ripple-framework/commit/b8decf191)) + +### ❤️ Contributors + +- Dylan Kelly +- David Featherston +- Jason Smith +- Jeffrey Dowdle + ## v2.16.0 [compare changes](https://github.com/dpc-sdp/ripple-framework/compare/2.15.0...v2.16.0) diff --git a/examples/nuxt-app/package.json b/examples/nuxt-app/package.json index 81a1ec4fd1..0d364a6b25 100644 --- a/examples/nuxt-app/package.json +++ b/examples/nuxt-app/package.json @@ -34,6 +34,6 @@ "@nuxt/devtools": "^0.6.7", "cypress": "13.6.6", "node-fetch-native": "^1.4.1", - "nuxt": "3.11.2" + "nuxt": "3.13.2" } } diff --git a/examples/nuxt-app/test/features/landingpage/forms-analytics.feature b/examples/nuxt-app/test/features/landingpage/forms-analytics.feature new file mode 100644 index 0000000000..72e45da728 --- /dev/null +++ b/examples/nuxt-app/test/features/landingpage/forms-analytics.feature @@ -0,0 +1,12 @@ +Feature: Forms analytics events + + @mockserver + Scenario: Form start + Given the mock server has started + And the page endpoint for path "/" returns fixture "/landingpage/full-form" with status 200 + And the site endpoint returns fixture "/site/reference" with status 200 + Given I visit the page "/" + When I type "Cat" into the input with the label "Last name" + Then the dataLayer should include the following events + | event | form_id | form_name | component | platform_event | + | form_start | full_form | Test form | rpl-form | start | diff --git a/examples/nuxt-app/test/features/landingpage/forms.feature b/examples/nuxt-app/test/features/landingpage/forms.feature index 164057df44..55e63b0822 100644 --- a/examples/nuxt-app/test/features/landingpage/forms.feature +++ b/examples/nuxt-app/test/features/landingpage/forms.feature @@ -65,6 +65,10 @@ Feature: Forms | You must accept the terms | /kitchen-sink#i_accept_the_terms | Then clicking on an error summary link with text "Must choose a favourite colour" should focus on the input with ID "favourite_colour" + And the dataLayer should include the following events + | event | form_id | form_valid | element_text | component | + | form_submit | full_form | false | Submit | rpl-form | + @mockserver Scenario: Simple validation Given the mock server has started @@ -124,6 +128,9 @@ Feature: Forms And I click "Green" from the select field with label "Favourite colour" And I click "Free admission" from the select field with label "Term select" And I click "Seniors" from the select field with label "Term select" + And I click "Dog person" from the radio group with label "Type of person" + And I click "London" from the checkbox group with label "Favourite Locations" + And I click "Tokyo" from the checkbox group with label "Favourite Locations" And I toggle the checkbox with label "Terms and conditions" And I submit the form with ID "full_form" @@ -131,3 +138,46 @@ Feature: Forms Then a server message should be displayed above the form | status | title | description | | success | Server success | Test success message | + + And the dataLayer should include the following events + | event | label | form_id | field_id | type | value | component | + | update_form_field | First name | full_form | first_name | text | [redacted] | rpl-form-input | + | update_form_field | Last name | full_form | last_name | text | [redacted] | rpl-form-input | + | update_form_field | Email | full_form | email | email | [redacted] | rpl-form-input | + | update_form_field | Quantity | full_form | quantity | number | [redacted] | rpl-form-number | + | update_form_field | Website | full_form | website | url | [redacted] | rpl-form-input | + | update_form_field | Mobile phone | full_form | mobile_phone | tel | [redacted] | rpl-form-input | + | update_form_field | Message | full_form | message | textarea | [redacted] | rpl-form-textarea | + | open_form_field | Favourite colour | full_form | favourite_colour | select | | rpl-form-dropdown | + | update_form_field | Favourite colour | full_form | favourite_colour | select | Green | rpl-form-dropdown | + | open_form_field | Term select | full_form | term_select | select | | rpl-form-dropdown | + | update_form_field | Term select | full_form | term_select | select | Free admission | rpl-form-dropdown | + | open_form_field | Term select | full_form | term_select | select | Free admission | rpl-form-dropdown | + | update_form_field | Term select | full_form | term_select | select | Free admission,Seniors | rpl-form-dropdown | + | update_form_field | Type of person | full_form | person_type | radio | Dog person | rpl-form-radio-group | + | update_form_field | Favourite Locations | full_form | favourite_locations | checkbox | London | rpl-form-checkbox-group | + | update_form_field | Favourite Locations | full_form | favourite_locations | checkbox | London,Tokyo | rpl-form-checkbox-group | + | update_form_field | I accept the terms | full_form | i_accept_the_terms__checkbox | checkbox | true | rpl-form-option | + + And the dataLayer should include the following events + | event | form_id | form_valid | element_text | component | + | form_submit | full_form | true | Submit | rpl-form | + | form_complete | full_form | | Submit | rpl-form | + + And the dataLayer form data for "form_complete" should include the following values + | key | value | + | first_name | [redacted] | + | last_name | [redacted] | + | role | [redacted] | + | email | [redacted] | + | quantity | [redacted] | + | website | [redacted] | + | mobile_phone | [redacted] | + | dob | [redacted] | + | message | [redacted] | + | favourite_colour | Green | + | term_select | Free admission,Seniors | + | person_type | Dog person | + | favourite_locations | London,Tokyo | + | i_accept_the_terms | true | + | site_section | DPC | diff --git a/examples/nuxt-app/test/features/maps/maps.feature b/examples/nuxt-app/test/features/maps/maps.feature index e6b8374ae3..ce1b53d273 100644 --- a/examples/nuxt-app/test/features/maps/maps.feature +++ b/examples/nuxt-app/test/features/maps/maps.feature @@ -147,3 +147,11 @@ Feature: Custom collection map component When I click the exit fullscreen button And I wait 100 milliseconds Then the map should not be fullscreen + + @mockserver + Scenario: Map height can be customised + Given I load the page fixture with "/maps/basic-page" + And the map height is set to 606 + Then the page endpoint for path "/map" returns the loaded fixture + When I visit the page "/map" + Then the map height is 606 diff --git a/examples/nuxt-app/test/features/publication/publication.feature b/examples/nuxt-app/test/features/publication/publication.feature index e2ec52b2f5..c9bb405b8f 100644 --- a/examples/nuxt-app/test/features/publication/publication.feature +++ b/examples/nuxt-app/test/features/publication/publication.feature @@ -33,6 +33,7 @@ Feature: Publication page @mockserver Example: Publication child + Given the page endpoint for path "/victorian-skills-plan-2023-implementation-update/promoting-post-secondary-education-skills-and-career" returns fixture "/publication/sample-publication-page" with status 200 When I visit the page "/victorian-skills-plan-2023-implementation-update/2022-victorian-skills-plan-actions-and-initiatives" Then the title should be "The Victorian Skills Plan 2022 into 2023 actions and initiatives" And there should be a page link with a title of "Previous" and description text of "Victorian Skills Plan Implementation Update" @@ -41,6 +42,12 @@ Feature: Publication page | title | url | type | size | | Victorian Skills Plan Implementation Update October 2023 | /sites/default/files/2023-10/16686-VSA-Implementation-Plan-Section_FA_Digital.pdf | pdf | 4.61 MB | | Print full document | /victorian-skills-plan-2023-implementation-update/print-all | | | + + When I click on the "Next" page link + Then the dataLayer should include the following events + | event | element_text | link_url | name | count | index | component | + | paginate_next | Next | /victorian-skills-plan-2023-implementation-update/promoting-post-secondary-education-skills-and-career | Promoting post-secondary education skills and career pathways | 28 | 2 | rpl-page-links | + When I click on the document "Print full document" Then the dataLayer should include the following events | event | element_text | link_url | component | diff --git a/examples/nuxt-app/test/features/search-listing/analytics.feature b/examples/nuxt-app/test/features/search-listing/analytics.feature new file mode 100644 index 0000000000..a57fc307fc --- /dev/null +++ b/examples/nuxt-app/test/features/search-listing/analytics.feature @@ -0,0 +1,83 @@ +Feature: Search listing - Analytics + + Background: + Given the site endpoint returns fixture "/site/reference" with status 200 + And the search autocomplete request is stubbed with "/search-listing/suggestions/none" fixture + And I am using a "macbook-16" device + + @mockserver + Example: Filters emit data for analytics + Given the page endpoint for path "/filters" returns fixture "/search-listing/filters/page" with status 200 + And the search network request is stubbed with fixture "/search-listing/filters/response" and status 200 + When I visit the page "/filters" + And I toggle the search listing filters section + + When I click the search listing dropdown field labelled "Term filter example" + Then I click the option labelled "Blue" in the selected dropdown + Then I click the option labelled "Green" in the selected dropdown + And I click the search listing dropdown field labelled "Term filter example" + + When I click the search listing dropdown field labelled "Single term filter example" + Then I click the option labelled "Aqua" in the selected dropdown + When I click the search listing dropdown field labelled "Single term filter example" + Then I click the option labelled "Select a single colour" in the selected dropdown + + When I click the search listing checkbox field labelled "Show archived content" + And I click the search listing checkbox field labelled "Show archived content" + + When I click the search listing checkbox field labelled "Weekdays" + Then I click the search listing checkbox field labelled "Weekends" + And I click the search listing checkbox field labelled "Weekdays" + And I click the search listing checkbox field labelled "Weekends" + Then I submit the search filters + + And the dataLayer should include the following events + | event | label | form_name | form_id | field_id | type | value | component | + | open_form_field | Term filter example | Grants and programs | tide-search-filter-form | termFilter | select | | rpl-form-dropdown | + | update_form_field | Term filter example | Grants and programs | tide-search-filter-form | termFilter | select | Blue | rpl-form-dropdown | + | update_form_field | Term filter example | Grants and programs | tide-search-filter-form | termFilter | select | Blue,Green | rpl-form-dropdown | + | close_form_field | Term filter example | Grants and programs | tide-search-filter-form | termFilter | select | Blue,Green | rpl-form-dropdown | + | open_form_field | Single term filter example | Grants and programs | tide-search-filter-form | singleTermFilter | select | | rpl-form-dropdown | + | update_form_field | Single term filter example | Grants and programs | tide-search-filter-form | singleTermFilter | select | Aqua | rpl-form-dropdown | + | open_form_field | Single term filter example | Grants and programs | tide-search-filter-form | singleTermFilter | select | Aqua | rpl-form-dropdown | + | update_form_field | Single term filter example | Grants and programs | tide-search-filter-form | singleTermFilter | select | | rpl-form-dropdown | + | update_form_field | Show archived content | Grants and programs | tide-search-filter-form | checkboxFilter__checkbox | checkbox | Archived | rpl-form-option | + | update_form_field | Show archived content | Grants and programs | tide-search-filter-form | checkboxFilter__checkbox | checkbox | false | rpl-form-option | + | update_form_field | Checkbox group | Grants and programs | tide-search-filter-form | checkboxFilterGroup | checkbox | Weekdays | rpl-form-checkbox-group | + | update_form_field | Checkbox group | Grants and programs | tide-search-filter-form | checkboxFilterGroup | checkbox | Weekdays,Weekends | rpl-form-checkbox-group | + | update_form_field | Checkbox group | Grants and programs | tide-search-filter-form | checkboxFilterGroup | checkbox | Weekends | rpl-form-checkbox-group | + | update_form_field | Checkbox group | Grants and programs | tide-search-filter-form | checkboxFilterGroup | checkbox | | rpl-form-checkbox-group | + + @mockserver + Example: Dependent filter - emits analytics data as expected + Given the page endpoint for path "/filters" returns fixture "/search-listing/dependent-filters/page" with status 200 + And the search network request is stubbed with fixture "/search-listing/dependent-filters/response" and status 200 + When I visit the page "/filters" + And I toggle the search listing filters section + + Then I click the search listing dropdown field labelled "Terms dependent example" + And I click the option labelled "Mammals" in the selected dropdown + Then I click the search listing dropdown field labelled "Terms dependent example" + + When I click the search listing dropdown field labelled "Terms dependent child example" + And I click the option labelled "Dogs" in the selected dropdown + Then I click the search listing dropdown field labelled "Terms dependent child example" + + When I click the search listing dropdown field labelled "Terms dependent grandchild example" + And I click the option labelled "Beagle" in the selected dropdown + And I click the option labelled "Spaniel" in the selected dropdown + Then I click the search listing dropdown field labelled "Terms dependent grandchild example" + Then I submit the search filters + + Then the dataLayer should include the following events + | event | label | form_name | form_id | field_id | type | value | component | + | open_form_field | Terms dependent example | Depenedent filters | tide-search-filter-form | dependentFilter-1 | select | | rpl-form-dropdown | + | update_form_field | Terms dependent example | Depenedent filters | tide-search-filter-form | dependentFilter-1 | select | Mammals | rpl-form-dropdown | + | close_form_field | Terms dependent example | Depenedent filters | tide-search-filter-form | dependentFilter-1 | select | Mammals | rpl-form-dropdown | + | open_form_field | Terms dependent child example | Depenedent filters | tide-search-filter-form | dependentFilter-2 | select | | rpl-form-dropdown | + | update_form_field | Terms dependent child example | Depenedent filters | tide-search-filter-form | dependentFilter-2 | select | Dogs | rpl-form-dropdown | + | close_form_field | Terms dependent child example | Depenedent filters | tide-search-filter-form | dependentFilter-2 | select | Dogs | rpl-form-dropdown | + | open_form_field | Terms dependent grandchild example | Depenedent filters | tide-search-filter-form | dependentFilter-3 | select | | rpl-form-dropdown | + | update_form_field | Terms dependent grandchild example | Depenedent filters | tide-search-filter-form | dependentFilter-3 | select | Beagle | rpl-form-dropdown | + | update_form_field | Terms dependent grandchild example | Depenedent filters | tide-search-filter-form | dependentFilter-3 | select | Beagle,Spaniel | rpl-form-dropdown | + | close_form_field | Terms dependent grandchild example | Depenedent filters | tide-search-filter-form | dependentFilter-3 | select | Beagle,Spaniel | rpl-form-dropdown | diff --git a/examples/nuxt-app/test/features/site/analytics.feature b/examples/nuxt-app/test/features/site/analytics.feature index 18110e28e5..f38ae3c640 100644 --- a/examples/nuxt-app/test/features/site/analytics.feature +++ b/examples/nuxt-app/test/features/site/analytics.feature @@ -1,16 +1,19 @@ Feature: Analytics + Background: + Given I am using a "macbook-16" device + @mockserver - Scenario: DataLayer - page view + Scenario: Page view Given the site endpoint returns fixture "/site/reference" with status 200 And the page endpoint for path "/" returns fixture "/landingpage/home" with status 200 Given I visit the page "/" Then the dataLayer should include the following events - | event | page_title | page_url | content_type | - | routeChange | Demo Landing Page | / | landing_page | + | event | page_title | page_url | content_type | content_status | + | routeChange | Demo Landing Page | / | landing_page | published | @mockserver - Scenario: DataLayer - breadcrumbs + Scenario: Breadcrumbs Given the site endpoint returns fixture "/site/vic" with status 200 And the page endpoint for path "/education" returns fixture "/landingpage/home" with status 200 Given I visit the page "/education" @@ -18,3 +21,12 @@ Feature: Analytics | title | | Information and services | | Education | + + @mockserver + Scenario: Back to top + Given the site endpoint returns fixture "/site/vic" with status 200 + And the page endpoint for path "/" returns fixture "/landingpage/home" with status 200 + Given I visit the page "/" + Then I scroll 1900 pixels + And I click the back to top button + Then the dataLayer back to top event should have a value of 25 diff --git a/examples/nuxt-app/test/fixtures/landingpage/full-form.json b/examples/nuxt-app/test/fixtures/landingpage/full-form.json index 4c2f1c6981..8e06127c6b 100644 --- a/examples/nuxt-app/test/fixtures/landingpage/full-form.json +++ b/examples/nuxt-app/test/fixtures/landingpage/full-form.json @@ -52,6 +52,7 @@ "id": 1119, "title": "Test form", "props": { + "title": "Test form", "formId": "full_form", "hideFormOnSubmit": false, "successMessageTitle": "Server success", @@ -92,7 +93,8 @@ "validation": [[["matches", ",/^\\W*(\\w+(\\W+|$)){0,5}$/"]]], "validationMessages": { "matches": "You can enter a maximum of 5 words" - } + }, + "pii": true }, { "$formkit": "RplFormEmail", @@ -103,7 +105,8 @@ "validation": "email", "validationMessages": { "email": "Email must be a valid email address" - } + }, + "pii": true }, { "$formkit": "RplFormNumber", @@ -111,7 +114,8 @@ "label": "Quantity", "id": "quantity", "validation": "", - "validationMessages": {} + "validationMessages": {}, + "pii": true }, { "$formkit": "RplFormUrl", @@ -120,7 +124,8 @@ "id": "website", "help": "Enter a URL", "validation": "", - "validationMessages": {} + "validationMessages": {}, + "pii": true }, { "$formkit": "RplFormTel", @@ -128,7 +133,8 @@ "label": "Mobile phone", "id": "mobile_phone", "validation": "", - "validationMessages": {} + "validationMessages": {}, + "pii": true }, { "$formkit": "RplFormDatePicker", @@ -136,7 +142,8 @@ "label": "Date of birth", "id": "dob", "validation": "", - "validationMessages": {} + "validationMessages": {}, + "pii": true }, { "$formkit": "RplFormTextarea", @@ -151,7 +158,8 @@ "validationMessages": { "required": "The message field is required", "matches": "Please enter between 10 and 50 characters" - } + }, + "pii": true }, { "$formkit": "RplFormDropdown", @@ -178,7 +186,8 @@ "validation": "required", "validationMessages": { "required": "Must choose a favourite colour" - } + }, + "pii": false }, { "$formkit": "RplFormDropdown", @@ -216,7 +225,8 @@ "validation": "length:0,2", "validationMessages": { "length": "Must choose at least two" - } + }, + "pii": false }, { "$formkit": "RplFormRadioGroup", @@ -239,7 +249,8 @@ "value": "8986", "label": "Bird person" } - ] + ], + "pii": false }, { "$formkit": "RplFormCheckboxGroup", @@ -263,7 +274,8 @@ "value": "tokyo", "label": "Tokyo" } - ] + ], + "pii": false }, { "$formkit": "RplFormCheckbox", @@ -276,13 +288,15 @@ "validationMessages": { "required": "You must accept the terms", "accepted": "You must accept the terms" - } + }, + "pii": false }, { "$formkit": "RplFormHidden", "name": "site_section", "id": "site_section", - "value": "DPC" + "value": "DPC", + "pii": false }, { "$formkit": "RplFormActions", diff --git a/examples/nuxt-app/test/fixtures/landingpage/home.json b/examples/nuxt-app/test/fixtures/landingpage/home.json index 96e9373f7c..d9f49b2691 100644 --- a/examples/nuxt-app/test/fixtures/landingpage/home.json +++ b/examples/nuxt-app/test/fixtures/landingpage/home.json @@ -3,6 +3,7 @@ "changed": "2022-11-02T12:47:29+11:00", "created": "2022-11-02T12:47:29+11:00", "type": "landing_page", + "status": "published", "nid": "11dede11-10c0-111e1-1100-000000000330", "showTopicTags": true, "topicTags": [ diff --git a/lerna.json b/lerna.json index f881815de6..fd5b3b779e 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.16.0", + "version": "2.17.0", "npmClient": "pnpm", "exact": true, "command": { diff --git a/package.json b/package.json index 8b2650469e..8dbfb6df54 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,7 @@ "postcss-preset-env": "^8.1.0", "start-server-and-test": "^2.0.3", "stylelint": "^15.10.2", - "ts-jest": "^29.0.5", - "typescript": "^5.0.2" + "ts-jest": "^29.0.5" }, "engines": { "node": "^18.15.0 || ^20.9.0", @@ -88,7 +87,8 @@ }, "dependencies": { "@vue/vue3-jest": "^29.2.3", - "rimraf": "^4.4.1" + "rimraf": "^4.4.1", + "typescript": "5.0.2" }, "pnpm": { "overrides": { diff --git a/packages/eslint-config-ripple/package.json b/packages/eslint-config-ripple/package.json index 374ffbba86..6df30432da 100644 --- a/packages/eslint-config-ripple/package.json +++ b/packages/eslint-config-ripple/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/eslint-config-ripple", "description": "ESLint config for Ripple projects", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "index.js", diff --git a/packages/nuxt-ripple-analytics/lib/index.ts b/packages/nuxt-ripple-analytics/lib/index.ts index 7e8d058ddb..5116a65567 100644 --- a/packages/nuxt-ripple-analytics/lib/index.ts +++ b/packages/nuxt-ripple-analytics/lib/index.ts @@ -375,6 +375,8 @@ export default { element_text: payload?.text, link_url: payload?.value, name: payload?.name, + count: payload?.count, + index: payload?.index, component: 'rpl-page-links', platform_event: 'paginate' }) @@ -562,7 +564,41 @@ export default { }) } }, + 'rpl-layout-back-to-top/navigate': () => { + return (payload: any) => { + trackEvent({ + event: `${payload.action}_back_to_top`, + element_text: payload?.text, + value: payload?.value, + component: 'rpl-layout-back-to-top', + platform_event: 'navigate' + }) + } + }, // UI Forms components + 'rpl-form/start': () => { + return (payload: any) => { + trackEvent({ + event: `form_start`, + form_id: payload?.id, + form_name: payload?.name, + component: 'rpl-form', + platform_event: 'start' + }) + } + }, + 'rpl-form/abandon': () => { + return (payload: any) => { + trackEvent({ + event: `form_abandon`, + form_id: payload?.id, + form_name: payload?.name, + component: 'rpl-form', + platform_event: 'abandon', + form_data: payload?.value + }) + } + }, 'rpl-form/submit': () => { return (payload: any) => { trackEvent({ @@ -676,6 +712,21 @@ export default { }) } }, + 'rpl-form-number/update': () => { + return (payload: any) => { + trackEvent({ + event: `${payload.action}_form_field`, + label: payload?.label, + form_name: payload?.contextName, + form_id: payload?.contextId, + field_id: payload?.id, + type: payload?.type, + value: payload?.value, + component: 'rpl-form-number', + platform_event: 'update' + }) + } + }, 'rpl-form-option/update': () => { return (payload: any) => { trackEvent({ diff --git a/packages/nuxt-ripple-analytics/lib/routeChange.ts b/packages/nuxt-ripple-analytics/lib/routeChange.ts index 078a0f7f16..5a323aa5e6 100644 --- a/packages/nuxt-ripple-analytics/lib/routeChange.ts +++ b/packages/nuxt-ripple-analytics/lib/routeChange.ts @@ -10,6 +10,7 @@ export default function ({ route, site, page }): IRplAnalyticsEventPayload { page_title: page?.title, page_url: route.fullPath, content_type: page?.type, + content_status: page?.status, publication_name: page?.publication?.text, search_term: trimValue(route.query?.q), site_section: page?.siteSection?.name, diff --git a/packages/nuxt-ripple-analytics/lib/tracker.ts b/packages/nuxt-ripple-analytics/lib/tracker.ts index ec0773e317..86b983f8f8 100644 --- a/packages/nuxt-ripple-analytics/lib/tracker.ts +++ b/packages/nuxt-ripple-analytics/lib/tracker.ts @@ -31,6 +31,7 @@ export interface IRplAnalyticsEventPayload { // Route properties status_code?: number content_type?: string + content_status?: string search_term?: string site_section?: string publication_name?: string diff --git a/packages/nuxt-ripple-analytics/package.json b/packages/nuxt-ripple-analytics/package.json index 9cd9f1918f..c52389517d 100644 --- a/packages/nuxt-ripple-analytics/package.json +++ b/packages/nuxt-ripple-analytics/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/nuxt-ripple-analytics", "description": "Nuxt module for handling event tracking.", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "main": "./nuxt.config.ts", "repository": "https://github.com/dpc-sdp/ripple-framework", diff --git a/packages/nuxt-ripple-cli/package.json b/packages/nuxt-ripple-cli/package.json index 5c01756731..6e30a128b0 100644 --- a/packages/nuxt-ripple-cli/package.json +++ b/packages/nuxt-ripple-cli/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/nuxt-ripple-cli", "description": "A CLI for simplifying common setup and scaffolding tasks", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./dist/index.js", diff --git a/packages/nuxt-ripple-cli/src/commands/init/_templates/layer/latest/package.json.t b/packages/nuxt-ripple-cli/src/commands/init/_templates/layer/latest/package.json.t index e0f4c5eff2..46878831b4 100644 --- a/packages/nuxt-ripple-cli/src/commands/init/_templates/layer/latest/package.json.t +++ b/packages/nuxt-ripple-cli/src/commands/init/_templates/layer/latest/package.json.t @@ -48,7 +48,7 @@ to: package.json "cypress": "^13.6.6", "eslint": "^8.28.0", "jest-environment-jsdom": "^29.5.0", - "nuxt": "3.11.2", + "nuxt": "3.13.2", "start-server-and-test": "^2.0.0", "ts-jest": "^29.1.0", "typescript": "^4.9.3" diff --git a/packages/nuxt-ripple-cli/src/commands/init/_templates/site/latest/package.json.t b/packages/nuxt-ripple-cli/src/commands/init/_templates/site/latest/package.json.t index 9e2edbf241..937a1f4f80 100644 --- a/packages/nuxt-ripple-cli/src/commands/init/_templates/site/latest/package.json.t +++ b/packages/nuxt-ripple-cli/src/commands/init/_templates/site/latest/package.json.t @@ -30,7 +30,7 @@ to: package.json }, "devDependencies": { "@dpc-sdp/eslint-config-ripple": "<%= rplVersion %>", - "nuxt": "3.11.2", + "nuxt": "3.13.2", "eslint": "^8.28.0" }, "engines": { diff --git a/packages/nuxt-ripple-preview/package.json b/packages/nuxt-ripple-preview/package.json index aee6361d9f..c6c1c7baa3 100644 --- a/packages/nuxt-ripple-preview/package.json +++ b/packages/nuxt-ripple-preview/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/nuxt-ripple-preview", "description": "Adds support for drupal preview links in Ripple frontend sites", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "main": "./nuxt.config.ts", "repository": "https://github.com/dpc-sdp/ripple-framework", diff --git a/packages/nuxt-ripple/components/TideContentRating.vue b/packages/nuxt-ripple/components/TideContentRating.vue index eccfdf2e08..2560eee29b 100644 --- a/packages/nuxt-ripple/components/TideContentRating.vue +++ b/packages/nuxt-ripple/components/TideContentRating.vue @@ -83,7 +83,7 @@ onMounted(() => { { const { public: config } = useRuntimeConfig() + const formId = event.context.params?.formId + + if (!formId) { + throw new BadRequestError('Form id is required') + } + + try { + await verifyCaptcha(event) + } catch (error) { + logger.error(`CAPTCHA validation error`, { + error, + label: 'TideWebformHandler' + }) + sendError( + event, + createError({ + statusCode: 400, + statusMessage: 'Unable to verify CAPTCHA' + }) + ) + return + } const proxyMiddleware = createProxyMiddleware({ target: config.tide.baseUrl, diff --git a/packages/nuxt-ripple/server/utils/verifyCaptcha.ts b/packages/nuxt-ripple/server/utils/verifyCaptcha.ts new file mode 100644 index 0000000000..9ce7b212d8 --- /dev/null +++ b/packages/nuxt-ripple/server/utils/verifyCaptcha.ts @@ -0,0 +1,198 @@ +import { useRuntimeConfig } from '#imports' +import { logger } from '@dpc-sdp/ripple-tide-api' +import { + ApplicationError, + UnauthorisedError +} from '@dpc-sdp/ripple-tide-api/errors' + +import type { H3Event } from 'h3' +import { + CaptchaType, + MappedCaptchaConfig +} from '@dpc-sdp/ripple-tide-webform/types' + +const logLabel = 'Verify CAPTCHA' + +const genericCaptchaVerify = async ( + verifyUrl: string, + secretKey: string, + responseToken: string, + responseCallback: (response: any, statusCode: number) => boolean +) => { + try { + const formData = new FormData() + + formData.append('secret', secretKey) + formData.append('response', responseToken) + + const response = await $fetch.raw(verifyUrl, { + method: 'POST', + body: formData + }) + + const statusCode = response.status + const verifyResponse = response._data + + const isValid = responseCallback(verifyResponse, statusCode) + + if (!isValid) { + logger.error('CAPTCHA verification failed', { + label: logLabel, + statusCode, + response: verifyResponse + }) + } + + return isValid + } catch (error) { + return false + } +} + +const verifyGoogleRecaptchaV3 = async ( + secretKey: string, + captchaConfig: MappedCaptchaConfig, + captchaResponse: string +) => { + const defaultScoreThreshold = 0.5 + + const scoreThreshold = + typeof captchaConfig?.scoreThreshold === 'number' + ? captchaConfig?.scoreThreshold + : defaultScoreThreshold + + return await genericCaptchaVerify( + 'https://www.google.com/recaptcha/api/siteverify', + secretKey, + captchaResponse, + (verifyResponse) => { + return ( + !!verifyResponse?.success && verifyResponse?.score >= scoreThreshold + ) + } + ) +} + +const verifyGoogleRecaptchaV2 = async ( + secretKey: string, + captchaResponse: string +) => { + return await genericCaptchaVerify( + 'https://www.google.com/recaptcha/api/siteverify', + secretKey, + captchaResponse, + (verifyResponse, statusCode) => { + const QUOTA_EXCEEDED_STATUS_CODE = 429 + + if (statusCode === QUOTA_EXCEEDED_STATUS_CODE) { + // If any quotas are hit, allow the submission + // We don't want to block genuine requests to the form, but we log an error + logger.error('Recaptcha V2 quota reached, allowing all submissions', { + label: logLabel + }) + + return true + } + + return !!verifyResponse?.success + } + ) +} + +const verifyCloudfareTurnstile = async ( + secretKey: string, + captchaResponse: string +) => { + return await genericCaptchaVerify( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + secretKey, + captchaResponse, + (verifyResponse) => { + return !!verifyResponse?.success + } + ) +} + +const verify = async ( + secretKey: string, + captchaConfig: MappedCaptchaConfig, + captchaResponse?: string +) => { + if (!captchaResponse) { + return false + } + + switch (captchaConfig?.type) { + case CaptchaType.RECAPTCHA_V3: + return await verifyGoogleRecaptchaV3( + secretKey, + captchaConfig, + captchaResponse + ) + case CaptchaType.RECAPTCHA_V2: + return await verifyGoogleRecaptchaV2(secretKey, captchaResponse) + case CaptchaType.TURNSTILE: + return await verifyCloudfareTurnstile(secretKey, captchaResponse) + default: + return false + } +} + +const verifyCaptcha = async (event: H3Event) => { + const config = useRuntimeConfig() + const captchaResponse = getHeader(event, 'x-captcha-response') + const formId = event.context.params?.formId + let webform + + try { + webform = await $fetch('/api/tide/webform', { + baseURL: config.apiUrl || '', + params: { + id: formId + } + }) + } catch (error) { + throw new ApplicationError( + `Couldn't get webform data, unable to continue because we don't know if a captcha is required`, + { cause: error } + ) + } + + if (!webform) { + throw new ApplicationError( + `Couldn't get webform data, unable to continue because we don't know if a captcha is required` + ) + } + + const formHasCaptcha = webform?.captchaConfig?.enabled + + if (!formHasCaptcha) { + return true + } + + const secretKey = + config.tide.captchaSecret[webform?.captchaConfig?.siteIdentifier] + + if (!secretKey) { + throw new ApplicationError( + `Secret key missing for site identifier: ${webform?.captchaConfig?.siteIdentifier} (site key: ${webform?.captchaConfig?.siteKey})` + ) + } + + const isValid = await verify( + secretKey, + webform?.captchaConfig, + captchaResponse + ) + + if (!isValid) { + throw new UnauthorisedError('Failed to verify CAPTCHA response token') + } + + logger.info('CAPTCHA verification successful', { + label: logLabel, + formId + }) +} + +export default verifyCaptcha diff --git a/packages/ripple-sdp-core/package.json b/packages/ripple-sdp-core/package.json index ebf2ffdc40..3200fbb187 100644 --- a/packages/ripple-sdp-core/package.json +++ b/packages/ripple-sdp-core/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-sdp-core", "description": "SDP core content types", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./nuxt.config.ts", diff --git a/packages/ripple-storybook/package.json b/packages/ripple-storybook/package.json index 7f23ca43c4..97c421db2d 100644 --- a/packages/ripple-storybook/package.json +++ b/packages/ripple-storybook/package.json @@ -1,7 +1,7 @@ { "name": "ripple-storybook", "description": "Ripple Storybook instance", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "private": true, "repository": "https://github.com/dpc-sdp/ripple-framework", diff --git a/packages/ripple-test-utils/package.json b/packages/ripple-test-utils/package.json index 115bc4db79..9ff6283098 100644 --- a/packages/ripple-test-utils/package.json +++ b/packages/ripple-test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-test-utils", "description": "Test utils for Ripple sites", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "type": "module", "main": "./dist/config/index.js", @@ -27,9 +27,11 @@ "@bahmutov/cypress-esbuild-preprocessor": "^2.2.0", "@frsource/cypress-plugin-visual-regression-diff": "^3.3.10", "@testing-library/cypress": "^10.0.1", + "@types/node": "18.15.10", "cypress": "^13.6.6", "cypress-real-events": "^1.13.0", "mockttp": "^3.9.1", - "start-server-and-test": "^2.0.3" + "start-server-and-test": "^2.0.3", + "typescript": "5.0.2" } } diff --git a/packages/ripple-test-utils/src/config/index.ts b/packages/ripple-test-utils/src/config/index.ts index d85ab56fd3..6e336eb4ba 100644 --- a/packages/ripple-test-utils/src/config/index.ts +++ b/packages/ripple-test-utils/src/config/index.ts @@ -10,7 +10,7 @@ export const rplCypressConfigPlugin = async (on, config) => { on( 'file:preprocessor', createBundler({ - plugins: [createEsbuildPlugin(config)] + plugins: [createEsbuildPlugin(config) as any] }) ) on('task', { ...apiMocking }) diff --git a/packages/ripple-test-utils/step_definitions/common/navigation.ts b/packages/ripple-test-utils/step_definitions/common/navigation.ts index c5bbb74b39..17d48ee2c1 100644 --- a/packages/ripple-test-utils/step_definitions/common/navigation.ts +++ b/packages/ripple-test-utils/step_definitions/common/navigation.ts @@ -112,3 +112,15 @@ Then( }) } ) + +When(`I click on the {string} page link`, (label: string) => { + cy.get(`.rpl-page-links__link`).contains(`${label}`).click() +}) + +Then('I scroll {int} pixels', (pixels: number) => { + cy.scrollTo(0, pixels) +}) + +Then('I click the back to top button', () => { + cy.get('.rpl-back-to-top__button').click() +}) diff --git a/packages/ripple-test-utils/step_definitions/common/site/analytics.ts b/packages/ripple-test-utils/step_definitions/common/site/analytics.ts index 8ee6e86557..898852e4dc 100644 --- a/packages/ripple-test-utils/step_definitions/common/site/analytics.ts +++ b/packages/ripple-test-utils/step_definitions/common/site/analytics.ts @@ -5,21 +5,48 @@ Then( (dataTable: DataTable) => { const table = dataTable.hashes() + let eventIndex: { [key: string]: number } = {} + table.forEach((row) => { cy.window().then((window) => { const dataLayer = window.dataLayer + const events = dataLayer?.filter((i) => i.event === row.event) - const event = dataLayer.find((i) => i.event === row.event) + eventIndex[row.event] = + eventIndex[row.event] !== undefined ? eventIndex[row.event] + 1 : 0 - const updatedRow = Object.entries(row).reduce((acc, [key, value]) => { - return { - ...acc, - [key]: value - } - }, {}) + const columns = Object.entries(row).reduce( + ( + acc: Record, + [key, value]: [string, string | boolean | number | undefined] + ) => { + if (value !== '') { + if (!isNaN(Number(value))) value = Number(value) + else if (value === 'true') value = true + else if (value === 'false') value = false + } + + return { + ...acc, + [key]: value + } + }, + {} + ) - Object.keys(updatedRow).forEach((key) => { - expect(event[key]).to.contain(updatedRow[key]) + Object.keys(columns).forEach((key) => { + if ( + typeof columns[key] === 'boolean' || + typeof columns[key] === 'number' + ) { + expect(events?.[eventIndex[row.event]][key]).to.equal(columns[key]) + } else if (columns[key]) { + expect(events?.[eventIndex[row.event]][key]).to.contain( + columns[key] + ) + } else { + expect(events?.[eventIndex[row.event]][key]).to.be.undefined + } }) }) }) @@ -35,8 +62,44 @@ Then( const event = window.dataLayer?.find((i) => i.event === 'routeChange') table.forEach((row, index) => { - expect(event.breadcrumbs[index]).to.contain(row.title) + expect(event?.breadcrumbs[index]).to.contain(row.title) }) }) } ) + +Then( + 'the dataLayer form data for {string} should include the following values', + (name: string, dataTable: DataTable) => { + const table = dataTable.hashes() + + cy.window().then((window) => { + const event = window.dataLayer?.find((i) => i.event === name) + + table.forEach((row) => { + let value = event?.form_data[row.key] + + if (value === 'true') value = true + if (value === 'false') value = false + + expect(value).to.equal(value) + }) + }) + } +) + +Then( + 'the dataLayer back to top event should have a value of {int}', + (percentage: string) => { + cy.window().then((window) => { + const event = window.dataLayer?.find( + (i) => i.event === 'click_back_to_top' + ) + + expect(event?.value).to.be.within( + Number(percentage) - 2, + Number(percentage) + 2 + ) + }) + } +) diff --git a/packages/ripple-test-utils/step_definitions/components/forms.ts b/packages/ripple-test-utils/step_definitions/components/forms.ts index 2a67193b02..1d71778699 100644 --- a/packages/ripple-test-utils/step_definitions/components/forms.ts +++ b/packages/ripple-test-utils/step_definitions/components/forms.ts @@ -269,6 +269,30 @@ Then('I toggle the checkbox with label {string}', (label: string) => { cy.get('@field').find('input[type="checkbox"]').check({ force: true }) }) +Then( + 'I click {string} from the checkbox group with label {string}', + (option: string, label: string) => { + cy.get('legend.rpl-form-label') + .contains(label) + .closest('.rpl-form__outer') + .as('field') + + cy.get('@field').find(`label`).contains(option).click() + } +) + +Then( + 'I click {string} from the radio group with label {string}', + (option: string, label: string) => { + cy.get('legend.rpl-form-label') + .contains(label) + .closest('.rpl-form__outer') + .as('field') + + cy.get('@field').find(`label`).contains(option).click() + } +) + When('I submit the form with ID {string}', (formId: string) => { cy.get(`form#${formId}`).submit() }) diff --git a/packages/ripple-test-utils/step_definitions/components/maps.ts b/packages/ripple-test-utils/step_definitions/components/maps.ts index d1f21cd7e2..51c4276544 100644 --- a/packages/ripple-test-utils/step_definitions/components/maps.ts +++ b/packages/ripple-test-utils/step_definitions/components/maps.ts @@ -277,3 +277,13 @@ Then('the map should not be fullscreen', () => { expect(doc.fullscreenElement).to.be.null }) }) + +Given('the map height is set to {int}', (height: number) => { + cy.get('@pageFixture').then((response) => { + set(response, 'bodyComponents[0].props.mapConfig.props.height', height) + }) +}) + +Then('the map height is {int}', (height: number) => { + cy.get('.rpl-map__map').should('have.css', 'height', `${height}px`) +}) diff --git a/packages/ripple-tide-api/package.json b/packages/ripple-tide-api/package.json index 2aa73a040d..7e46491f8a 100644 --- a/packages/ripple-tide-api/package.json +++ b/packages/ripple-tide-api/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-tide-api", "description": "Ripple API endpoints for Tide Drupal backend", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./dist/index.js", @@ -23,7 +23,7 @@ "watch": "tsc -p tsconfig.json -w" }, "dependencies": { - "@nuxt/kit": "3.11.2", + "@nuxt/kit": "3.13.2", "axios": "^1.3.4", "change-case": "^4.1.2", "cheerio": "^1.0.0", @@ -40,7 +40,7 @@ "winston": "^3.8.2" }, "devDependencies": { - "@nuxt/schema": "3.11.2", + "@nuxt/schema": "3.13.2", "@types/cheerio": "^0.22.35", "axios-mock-adapter": "^1.21.3", "defu": "^6.1.2", diff --git a/packages/ripple-tide-api/src/utils/mapping-utils.test.ts b/packages/ripple-tide-api/src/utils/mapping-utils.test.ts index cc2bb58569..6453cdd4db 100644 --- a/packages/ripple-tide-api/src/utils/mapping-utils.test.ts +++ b/packages/ripple-tide-api/src/utils/mapping-utils.test.ts @@ -79,6 +79,11 @@ const field = { url: '/landing-page-cc-2', origin_url: '/landing-page-cc-2' }, + field_related_link: { + uri: 'internal:/contact-us', + title: 'Get in touch', + options: [] + }, field_paragraph_location: { langcode: null, country_code: 'AU', @@ -160,6 +165,11 @@ describe('ripple-tide-api/mapping utils', () => { text: '', url: '/landing-page-cc-2' }) + + expect(getLinkFromField(field, 'field_related_link')).toEqual({ + text: 'Get in touch', + url: '/contact-us' + }) }) it(`returns null on invalid api key`, () => { diff --git a/packages/ripple-tide-api/src/utils/mapping-utils.ts b/packages/ripple-tide-api/src/utils/mapping-utils.ts index 66007a0893..4f8ac3eeeb 100644 --- a/packages/ripple-tide-api/src/utils/mapping-utils.ts +++ b/packages/ripple-tide-api/src/utils/mapping-utils.ts @@ -145,10 +145,14 @@ export const getLinkFromField = ( return null } - return { - text: linkField.title || linkField.text || '', - url: linkField.url || linkField.origin_url || linkField.uri || '' + let text = linkField.title || linkField.text || '' + let url = linkField.url || linkField.origin_url || linkField.uri || '' + + if (url.startsWith('internal:')) { + url = url.replace(/^internal:/, '') } + + return { text, url } } export const getAddress = (field: drupalField) => { diff --git a/packages/ripple-tide-api/types.d.ts b/packages/ripple-tide-api/types.d.ts index 378076d319..72a4731461 100644 --- a/packages/ripple-tide-api/types.d.ts +++ b/packages/ripple-tide-api/types.d.ts @@ -304,6 +304,10 @@ export interface IRplFeatureFlags { [key: string]: boolean } } + /** + * @description Collapse inner links in breadcrumbs + */ + breadcrumbsCollapseInnerLinks?: boolean /** * @description Custom flags */ diff --git a/packages/ripple-tide-event/package.json b/packages/ripple-tide-event/package.json index b1759c38dd..ad1e0c48a0 100644 --- a/packages/ripple-tide-event/package.json +++ b/packages/ripple-tide-event/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-tide-event", "description": "Ripple mappings and components for Tide Event Content type", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "main": "./nuxt.config.ts", "repository": "https://github.com/dpc-sdp/ripple-framework", diff --git a/packages/ripple-tide-grant/components/TideGrantDocuments.vue b/packages/ripple-tide-grant/components/TideGrantDocuments.vue index 04d612f8f1..ef8ed01c09 100644 --- a/packages/ripple-tide-grant/components/TideGrantDocuments.vue +++ b/packages/ripple-tide-grant/components/TideGrantDocuments.vue @@ -29,3 +29,11 @@ interface Props { defineProps() + + diff --git a/packages/ripple-tide-grant/package.json b/packages/ripple-tide-grant/package.json index faa04bc161..0a6bf1dc13 100644 --- a/packages/ripple-tide-grant/package.json +++ b/packages/ripple-tide-grant/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-tide-grant", "description": "Ripple mappings and components for Tide Grant Content type", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./nuxt.config.ts", diff --git a/packages/ripple-tide-landing-page/components/global/TideLandingPage/WebForm.vue b/packages/ripple-tide-landing-page/components/global/TideLandingPage/WebForm.vue index 3b3f5a37da..54bac61b8b 100644 --- a/packages/ripple-tide-landing-page/components/global/TideLandingPage/WebForm.vue +++ b/packages/ripple-tide-landing-page/components/global/TideLandingPage/WebForm.vue @@ -1,6 +1,7 @@ diff --git a/packages/ripple-tide-publication/components/TidePublicationSidebar.vue b/packages/ripple-tide-publication/components/TidePublicationSidebar.vue index 4a1f1353cd..a12526300a 100644 --- a/packages/ripple-tide-publication/components/TidePublicationSidebar.vue +++ b/packages/ripple-tide-publication/components/TidePublicationSidebar.vue @@ -7,9 +7,9 @@ @@ -17,28 +17,12 @@ diff --git a/packages/ripple-tide-publication/components/global/TidePublication.vue b/packages/ripple-tide-publication/components/global/TidePublication.vue index cce186dcd1..af2ae8850b 100644 --- a/packages/ripple-tide-publication/components/global/TidePublication.vue +++ b/packages/ripple-tide-publication/components/global/TidePublication.vue @@ -38,6 +38,7 @@ @@ -49,12 +50,24 @@ diff --git a/packages/ripple-tide-publication/components/global/TidePublicationPage.vue b/packages/ripple-tide-publication/components/global/TidePublicationPage.vue index d9675bc203..29b3e5b0a2 100644 --- a/packages/ripple-tide-publication/components/global/TidePublicationPage.vue +++ b/packages/ripple-tide-publication/components/global/TidePublicationPage.vue @@ -36,11 +36,13 @@ > @@ -51,12 +53,24 @@ diff --git a/packages/ripple-tide-publication/package.json b/packages/ripple-tide-publication/package.json index 933e022c40..fa5934eb69 100644 --- a/packages/ripple-tide-publication/package.json +++ b/packages/ripple-tide-publication/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-tide-publication", "description": "Ripple mappings and components for Tide Publication Content type", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./nuxt.config.ts", diff --git a/packages/ripple-tide-publication/types.ts b/packages/ripple-tide-publication/types.ts index 4e1ec5230a..376171cc12 100644 --- a/packages/ripple-tide-publication/types.ts +++ b/packages/ripple-tide-publication/types.ts @@ -1,6 +1,7 @@ import type { - TidePageBase, - TideDynamicPageComponent + TideDocumentField, + TideDynamicPageComponent, + TidePageBase } from '@dpc-sdp/ripple-tide-api/types' export type TidePublicationHeader = { @@ -14,6 +15,13 @@ export type TidePublicationChapter = { url: string } +export type TidePublication = { + text: string + url: string + id: string + documents: TideDocumentField[] +} + export interface apiNode { title: string url: string @@ -29,6 +37,8 @@ export interface indexNode { active: boolean | undefined } +export type flatIndexNode = Omit + export interface TidePublicationPage extends TidePageBase { /** * @description Page title @@ -54,13 +64,7 @@ export interface TidePublicationPage extends TidePageBase { * @description Landing page components */ bodyComponents: TideDynamicPageComponent[] - /** - * @description Page action documents - */ - // documents: any - - publication: Array - // children: Array + publication: TidePublication breadcrumbs: Array } diff --git a/packages/ripple-tide-publication/utils/processMenu.ts b/packages/ripple-tide-publication/utils/processMenu.ts index ced1940112..37f23f34a0 100644 --- a/packages/ripple-tide-publication/utils/processMenu.ts +++ b/packages/ripple-tide-publication/utils/processMenu.ts @@ -1,5 +1,5 @@ import type { RouteRecord } from 'vue-router' -import type { indexNode } from '../types' +import type { indexNode, flatIndexNode } from '../types' const parseChildren = (node: indexNode, route: RouteRecord): indexNode[] => { if (!node.items) { @@ -40,4 +40,11 @@ const processMenu = (res: indexNode, route: RouteRecord): indexNode[] => { ] } -export { type indexNode, processMenu } +const flattenMenu = (items?: indexNode[]): flatIndexNode[] => { + return (items || []).reduce((acc: flatIndexNode[], item): flatIndexNode[] => { + const { items = [], ...values } = item + return [...acc, values, ...flattenMenu(items)] + }, []) +} + +export { type indexNode, processMenu, flattenMenu } diff --git a/packages/ripple-tide-search/components/global/TideCustomCollection.vue b/packages/ripple-tide-search/components/global/TideCustomCollection.vue index fd5b678895..a4aac77550 100644 --- a/packages/ripple-tide-search/components/global/TideCustomCollection.vue +++ b/packages/ripple-tide-search/components/global/TideCustomCollection.vue @@ -540,7 +540,7 @@ const handleGeolocateSuccess = (pos: GeolocationPosition) => { const handleGeolocateError = () => { isGettingLocation.value = false - geolocationError.value = `We couldn't find your location. Check your browser permissions or input your location manually` + geolocationError.value = `We couldn't find your location. Check your browser permissions or input your location manually.` } const locationOrGeolocation = computed(() => { diff --git a/packages/ripple-tide-search/components/global/TideSearchFilterCheckbox.vue b/packages/ripple-tide-search/components/global/TideSearchFilterCheckbox.vue index f15f8909fd..ffcff64e9f 100644 --- a/packages/ripple-tide-search/components/global/TideSearchFilterCheckbox.vue +++ b/packages/ripple-tide-search/components/global/TideSearchFilterCheckbox.vue @@ -20,5 +20,6 @@ defineProps() :label="label" :checkboxLabel="checkboxLabel" :onValue="onValue" + :pii="false" /> diff --git a/packages/ripple-tide-search/components/global/TideSearchFilterCheckboxGroup.vue b/packages/ripple-tide-search/components/global/TideSearchFilterCheckboxGroup.vue index 611ed6dd11..2687c55a0c 100644 --- a/packages/ripple-tide-search/components/global/TideSearchFilterCheckboxGroup.vue +++ b/packages/ripple-tide-search/components/global/TideSearchFilterCheckboxGroup.vue @@ -21,5 +21,6 @@ defineProps() :variant="variant" :layout="layout" :options="options" + :pii="false" /> diff --git a/packages/ripple-tide-search/components/global/TideSearchFilterDependent.vue b/packages/ripple-tide-search/components/global/TideSearchFilterDependent.vue index ab6887413e..656378f6df 100644 --- a/packages/ripple-tide-search/components/global/TideSearchFilterDependent.vue +++ b/packages/ripple-tide-search/components/global/TideSearchFilterDependent.vue @@ -143,6 +143,7 @@ watch( :options="selectOptions[`${id}-${i}`] || []" :disabled="!selectOptions[`${id}-${i}`]?.length" :variant="variant" + :pii="false" /> diff --git a/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue b/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue index 74791d811c..57bd6bbc9e 100644 --- a/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue +++ b/packages/ripple-tide-search/components/global/TideSearchFilterDropdown.vue @@ -22,5 +22,6 @@ defineProps() :label="label" :placeholder="placeholder" :options="options" + :pii="false" /> diff --git a/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue b/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue index 677f0a10b4..4cd86780d1 100644 --- a/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue +++ b/packages/ripple-tide-search/components/global/TideSearchListingResultsMap.vue @@ -8,7 +8,7 @@ :features="features" projection="EPSG:3857" :popupType="popupType" - :map-height="550" + :map-height="height" :pinStyle="pinStyle" :noresults="noresults" :hasSidePanel="hasSidePanel" @@ -103,6 +103,7 @@ interface Props { initialising?: boolean clusteringDistance?: number maxZoom?: number + height?: number } const props = withDefaults(defineProps(), { @@ -117,7 +118,8 @@ const props = withDefaults(defineProps(), { hasSidePanel: false, initialising: false, clusteringDistance: undefined, - maxZoom: undefined + maxZoom: undefined, + height: 550 }) const appConfig = useAppConfig() diff --git a/packages/ripple-tide-search/package.json b/packages/ripple-tide-search/package.json index 79aa57ae0f..3f1e1782de 100644 --- a/packages/ripple-tide-search/package.json +++ b/packages/ripple-tide-search/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-tide-search", "description": "Ripple search UI and services for connecting to Tide search", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./nuxt.config.ts", diff --git a/packages/ripple-tide-topic/package.json b/packages/ripple-tide-topic/package.json index d0784f8a2e..72019c6adb 100644 --- a/packages/ripple-tide-topic/package.json +++ b/packages/ripple-tide-topic/package.json @@ -9,7 +9,7 @@ "./mapping": "./mapping/index.ts", "./types": "./types.ts" }, - "version": "2.16.0", + "version": "2.17.0", "dependencies": { "@dpc-sdp/nuxt-ripple": "workspace:*", "@dpc-sdp/ripple-tide-api": "workspace:*", diff --git a/packages/ripple-tide-webform/composables/use-webform-submit.ts b/packages/ripple-tide-webform/composables/use-webform-submit.ts index dd0d188a33..2c86c62475 100644 --- a/packages/ripple-tide-webform/composables/use-webform-submit.ts +++ b/packages/ripple-tide-webform/composables/use-webform-submit.ts @@ -1,8 +1,16 @@ import { $fetch } from 'ofetch' import { ref, useRuntimeConfig } from '#imports' - -export function useWebformSubmit(formId: string) { - const postForm = async (formId: string, formData = {}) => { +import { MappedCaptchaConfig } from '../types' + +export function useWebformSubmit( + formId: string, + captchaConfig?: MappedCaptchaConfig | null +) { + const postForm = async ( + formId: string, + formData = {}, + maybeCaptchaResponse: string | null + ) => { const { public: config } = useRuntimeConfig() const formResource = 'webform_submission' @@ -29,7 +37,8 @@ export function useWebformSubmit(formId: string) { site: config.tide.site }, headers: { - 'Content-Type': 'application/vnd.api+json;charset=UTF-8' + 'Content-Type': 'application/vnd.api+json;charset=UTF-8', + 'x-captcha-response': maybeCaptchaResponse || undefined } }) @@ -67,7 +76,11 @@ export function useWebformSubmit(formId: string) { return honeypotElement && !!honeypotElement.value } - const submitHandler = async (props: FormConfig, data: any) => { + const submitHandler = async ( + props: FormConfig, + data: any, + captchaWidgetId?: string + ) => { submissionState.value = { status: 'submitting', title: '', @@ -87,8 +100,30 @@ export function useWebformSubmit(formId: string) { return } + let maybeCaptchaResponse = null + + try { + maybeCaptchaResponse = await getCaptchaResponse( + formId, + captchaConfig, + captchaWidgetId, + window + ) + } catch (e) { + console.error(e) + + submissionState.value = { + status: 'error', + title: props.errorMessageTitle, + message: 'Invalid CAPTCHA', + receipt: '' + } + + return + } + try { - const resData = await postForm(props.formId, data) + const resData = await postForm(props.formId, data, maybeCaptchaResponse) const [code, note] = resData.attributes?.notes?.split('|') || [] diff --git a/packages/ripple-tide-webform/composables/useCaptcha.ts b/packages/ripple-tide-webform/composables/useCaptcha.ts new file mode 100644 index 0000000000..19dec91836 --- /dev/null +++ b/packages/ripple-tide-webform/composables/useCaptcha.ts @@ -0,0 +1,286 @@ +import { ref } from 'vue' +import { CaptchaType, MappedCaptchaConfig } from '../types' +import type { Script } from '@unhead/schema' +import { getCaptchaElementId } from '@dpc-sdp/ripple-ui-forms' + +const v3ScriptReadyEventNamePrefix = 'recaptchaV3Ready' +const v2ScriptReadyEventName = 'recaptchaV2Ready' +const turnstileScriptReadyEventName = 'turnstileReady' + +const getThirdPartyScript = ( + captchaConfig: MappedCaptchaConfig +): Script | null => { + switch (captchaConfig.type) { + case CaptchaType.RECAPTCHA_V3: { + const onLoadCallbackName = `onloadCallbackV3${captchaConfig.siteIdentifier}` + const eventName = `${v3ScriptReadyEventNamePrefix}-${captchaConfig.siteIdentifier}` + + if (window) { + window[onLoadCallbackName] = () => { + window.dispatchEvent(new Event(eventName)) + } + } + + return { + key: `${CaptchaType.RECAPTCHA_V3}-${captchaConfig.siteKey}`, + src: `https://www.google.com/recaptcha/api.js?render=${captchaConfig.siteKey}&onload=${onLoadCallbackName}`, + tagPosition: 'head', + async: true, + defer: true + } + } + case CaptchaType.RECAPTCHA_V2: { + const onLoadCallbackName = 'onloadCallbackV2' + + if (window) { + window[onLoadCallbackName] = () => { + window.dispatchEvent(new Event(v2ScriptReadyEventName)) + } + } + + return { + key: CaptchaType.RECAPTCHA_V2, + src: `https://www.google.com/recaptcha/api.js?render=explicit&onload=${onLoadCallbackName}`, + tagPosition: 'head', + async: true, + defer: true + } + } + case CaptchaType.TURNSTILE: { + const onLoadCallbackName = 'onloadCallbackTurnstile' + + if (window) { + window[onLoadCallbackName] = () => { + window.dispatchEvent(new Event(turnstileScriptReadyEventName)) + } + } + + return { + src: `https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=${onLoadCallbackName}`, + tagPosition: 'head', + async: true, + defer: true + } + } + default: + return null + } +} + +const initialiseCaptcha = ( + formId: string, + captchaConfig: MappedCaptchaConfig, + _window: any +) => { + const captchaElementId = getCaptchaElementId(formId) + + switch (captchaConfig.type as CaptchaType) { + case CaptchaType.RECAPTCHA_V2: + if (_window && _window.grecaptcha) { + return _window.grecaptcha.render(captchaElementId, { + sitekey: captchaConfig.siteKey, + theme: 'light' + }) + } + break + case CaptchaType.TURNSTILE: + if (_window && _window.turnstile) { + return _window.turnstile.render(`#${captchaElementId}`, { + sitekey: captchaConfig.siteKey, + // We set the 'action' as the formId so it can be identified in the analytics dashboard + action: formId + }) + } + break + default: + return null + } +} + +const reset = ( + widgetId: string, + captchaConfig: MappedCaptchaConfig, + _window: any +) => { + switch (captchaConfig.type as CaptchaType) { + case CaptchaType.RECAPTCHA_V2: + if (_window && _window.grecaptcha) { + _window.grecaptcha.reset(widgetId) + } + break + case CaptchaType.TURNSTILE: + if (_window && _window.turnstile) { + _window.turnstile.reset(widgetId) + } + break + default: + } +} + +const waitForV3Script = ( + captchaConfig: MappedCaptchaConfig, + readyCallback: () => void +) => { + if (window) { + if (window.grecaptcha?.render) { + readyCallback() + } else { + window.addEventListener( + `${v3ScriptReadyEventNamePrefix}-${captchaConfig.siteIdentifier}`, + () => { + readyCallback() + }, + false + ) + } + } +} + +const waitForV2Script = (readyCallback: () => void) => { + if (window) { + if (window.grecaptcha?.render) { + readyCallback() + } else { + window.addEventListener( + v2ScriptReadyEventName, + () => { + readyCallback() + }, + false + ) + } + } +} + +const waitForTurnstileScript = (readyCallback: () => void) => { + if (window) { + if (window.turnstile?.render) { + readyCallback() + } else { + window.addEventListener( + turnstileScriptReadyEventName, + () => { + readyCallback() + }, + false + ) + } + } +} + +export const useIsCaptchaReady = ( + captchaConfig?: MappedCaptchaConfig | null +): any => { + const isCaptchaReady = ref(false) + + if (!captchaConfig?.enabled) { + return isCaptchaReady + } + + switch (captchaConfig.type as CaptchaType) { + case CaptchaType.RECAPTCHA_V3: + waitForV3Script(captchaConfig, () => { + isCaptchaReady.value = true + }) + break + case CaptchaType.RECAPTCHA_V2: + waitForV2Script(() => { + isCaptchaReady.value = true + }) + break + case CaptchaType.TURNSTILE: + waitForTurnstileScript(() => { + isCaptchaReady.value = true + }) + break + default: + return false + } + + return isCaptchaReady +} + +export const useResetCaptcha = ( + widgetId: string | null, + captchaConfig: MappedCaptchaConfig | null +) => { + if (captchaConfig && widgetId !== null && widgetId !== undefined) { + reset(widgetId, captchaConfig, window) + } +} + +export const useCaptchaScripts = (captchaConfig: MappedCaptchaConfig): any => { + if (!captchaConfig?.enabled) { + return {} + } + + const scriptTag = getThirdPartyScript(captchaConfig) + + if (scriptTag) { + useHead( + { + script: [scriptTag] + }, + { + mode: 'client' + } + ) + } +} + +export const useCaptchaWidget = ( + formId: string, + captchaConfig: MappedCaptchaConfig | null +): any => { + const widgetId = ref(null) + + if (!captchaConfig?.enabled) { + return {} + } + + widgetId.value = initialiseCaptcha(formId, captchaConfig, window) + + return { + widgetId: widgetId.value, + resetCaptcha: () => { + if (widgetId.value !== null) { + reset(widgetId.value, captchaConfig, window) + } + } + } +} + +export const useCaptcha = ( + formId: string, + captchaConfig: MappedCaptchaConfig | null +) => { + const captchaWidgetId = ref(null) + + const isCaptchaScriptReady = useIsCaptchaReady(captchaConfig) + const isCaptchaElementReady = ref(false) + const isCaptchaReady = computed(() => { + return isCaptchaScriptReady.value && isCaptchaElementReady.value + }) + + // Sometimes the captcha element isn't rendered on load, so we provide a callback for telling us when it's ready + const onCaptchaElementReady = () => { + isCaptchaElementReady.value = true + + if (captchaConfig) { + useCaptchaScripts(captchaConfig) + } + } + + provide('onCaptchaElementReady', onCaptchaElementReady) + + watch(isCaptchaReady, (isReady) => { + if (isReady) { + const { widgetId } = useCaptchaWidget(formId, captchaConfig) + captchaWidgetId.value = widgetId + } + }) + + return { isCaptchaReady, captchaWidgetId } +} + +export default useCaptcha diff --git a/packages/ripple-tide-webform/mapping/index.ts b/packages/ripple-tide-webform/mapping/index.ts index 5da3acaf11..eb7efa3a3c 100644 --- a/packages/ripple-tide-webform/mapping/index.ts +++ b/packages/ripple-tide-webform/mapping/index.ts @@ -1 +1,2 @@ export { getFormSchemaFromMapping } from './webforms-mapping' +export { getCaptchaSettings } from './webforms-captcha' diff --git a/packages/ripple-tide-webform/mapping/webforms-captcha.ts b/packages/ripple-tide-webform/mapping/webforms-captcha.ts new file mode 100644 index 0000000000..09ed3781a1 --- /dev/null +++ b/packages/ripple-tide-webform/mapping/webforms-captcha.ts @@ -0,0 +1,30 @@ +import { + CaptchaType, + type ApiWebForm, + type MappedCaptchaConfig +} from './../types' +import { camelCase } from 'lodash-es' + +export const getCaptchaSettings = ( + webform: ApiWebForm +): MappedCaptchaConfig => { + const scoreThreshold = + webform?.third_party_settings?.tide_webform_captcha?.score_threshold + + const siteIdentifier = camelCase( + (webform?.third_party_settings?.tide_webform_captcha?.captcha_details + ?.captcha_id || '') as string + ) + + return { + enabled: + webform?.third_party_settings?.tide_webform_captcha?.enable_captcha === 1, + type: webform?.third_party_settings?.tide_webform_captcha + ?.captcha_type as CaptchaType, + siteKey: (webform?.third_party_settings?.tide_webform_captcha + ?.captcha_details?.site_key || '') as string, + siteIdentifier, + scoreThreshold: + typeof scoreThreshold === 'number' ? scoreThreshold : undefined + } +} diff --git a/packages/ripple-tide-webform/nuxt.config.ts b/packages/ripple-tide-webform/nuxt.config.ts index f716d9f9da..d27463d40b 100644 --- a/packages/ripple-tide-webform/nuxt.config.ts +++ b/packages/ripple-tide-webform/nuxt.config.ts @@ -1,3 +1,11 @@ import { defineNuxtConfig } from 'nuxt/config' -export default defineNuxtConfig({}) +export default defineNuxtConfig({ + runtimeConfig: { + tide: { + captchaSecret: { + // Placeholder for captcha id: secret mapping + } + } + } +}) diff --git a/packages/ripple-tide-webform/package.json b/packages/ripple-tide-webform/package.json index 8a68c0d89a..67a4f82c90 100644 --- a/packages/ripple-tide-webform/package.json +++ b/packages/ripple-tide-webform/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-tide-webform", "description": "Ripple mappings and components for Tide webforms", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "main": "./nuxt.config.ts", diff --git a/packages/ripple-tide-webform/server/api/tide/webform.ts b/packages/ripple-tide-webform/server/api/tide/webform.ts index 27d07e273d..ce573a6d89 100644 --- a/packages/ripple-tide-webform/server/api/tide/webform.ts +++ b/packages/ripple-tide-webform/server/api/tide/webform.ts @@ -14,7 +14,7 @@ import type { } from '@dpc-sdp/ripple-tide-api/types' import { useRuntimeConfig } from '#imports' import { AuthCookieNames } from '@dpc-sdp/nuxt-ripple-preview/utils' -import { getFormSchemaFromMapping } from '../../../mapping' +import { getFormSchemaFromMapping, getCaptchaSettings } from '../../../mapping' /** * @description Custom API call methods and response mapping for webform @@ -30,7 +30,8 @@ class TideWebformApi extends TideApiBase { _src: (field: any) => process.env.NODE_ENV === 'development' ? field : undefined, schema: async (field: any, page, tidePageApi: TidePageApi) => - await getFormSchemaFromMapping(field, tidePageApi) + await getFormSchemaFromMapping(field, tidePageApi), + captchaConfig: (field: any) => getCaptchaSettings(field) }, includes: [] } diff --git a/packages/ripple-tide-webform/types.ts b/packages/ripple-tide-webform/types.ts index 7c0d2fcb7a..1a3493e034 100644 --- a/packages/ripple-tide-webform/types.ts +++ b/packages/ripple-tide-webform/types.ts @@ -9,6 +9,7 @@ export interface TideWebform { errorMessageTitle: string errorMessageHTML: string schema: FormKitSchemaNode[] + captchaConfig: MappedCaptchaConfig } export interface TideWebformElement { @@ -37,6 +38,9 @@ export interface ApiWebForm { submission_exception_message?: string form_reset?: boolean } + third_party_settings?: { + tide_webform_captcha?: ApiCaptchaSettings + } } /** @@ -47,3 +51,28 @@ export interface ApiField { field_paragraph_title: string field_paragraph_webform: ApiWebForm } + +export enum CaptchaType { + RECAPTCHA_V2 = 'google_recaptcha_v2', + RECAPTCHA_V3 = 'google_recaptcha_v3', + TURNSTILE = 'cloudfare_turnstile' +} + +export interface ApiCaptchaSettings { + enable_captcha: 0 | 1 + captcha_type: CaptchaType + score_threshold: string | null + captcha_details: { + term_id: string + captcha_id: string + site_key: string + } +} + +export interface MappedCaptchaConfig { + enabled: boolean + type: CaptchaType + siteKey: string + siteIdentifier: string + scoreThreshold?: number +} diff --git a/packages/ripple-tide-webform/utils/captcha.ts b/packages/ripple-tide-webform/utils/captcha.ts new file mode 100644 index 0000000000..6e7b33f861 --- /dev/null +++ b/packages/ripple-tide-webform/utils/captcha.ts @@ -0,0 +1,70 @@ +import { CaptchaType, MappedCaptchaConfig } from '../types' + +const getGoogleRecaptchaV3Response = async ( + _window: any, + captchaConfig: MappedCaptchaConfig, + formId: string +) => { + return await _window?.grecaptcha.execute(captchaConfig.siteKey, { + // We set the 'action' as the formId so it can be identified in the analytics dashboard + action: formId + }) +} + +const getGoogleRecaptchaV2Response = ( + _window: any, + captchaWidgetId?: string +) => { + return _window?.grecaptcha.getResponse(captchaWidgetId) +} + +const getCloudfareTurnstileResponse = ( + _window: any, + captchaWidgetId?: string +) => { + return _window?.turnstile.getResponse(captchaWidgetId) +} + +const getResponse = async ( + formId: string, + captchaConfig: MappedCaptchaConfig, + captchaWidgetId?: string, + _window?: any +) => { + switch (captchaConfig.type) { + case CaptchaType.RECAPTCHA_V3: + return await getGoogleRecaptchaV3Response(_window, captchaConfig, formId) + case CaptchaType.RECAPTCHA_V2: + return getGoogleRecaptchaV2Response(_window, captchaWidgetId) + case CaptchaType.TURNSTILE: + return getCloudfareTurnstileResponse(_window, captchaWidgetId) + default: + throw new Error(`Captcha type not implemented: '${captchaConfig.type}'`) + } +} + +export const getCaptchaResponse = async ( + formId: string, + captchaConfig?: MappedCaptchaConfig, + captchaWidgetId?: string, + _window?: any +) => { + if (!captchaConfig?.enabled) { + return null + } + + const response = await getResponse( + formId, + captchaConfig, + captchaWidgetId, + _window + ) + + if (!response) { + throw new Error( + 'Failed to get captcha response token, but the form requires a CAPTCHA' + ) + } + + return response +} diff --git a/packages/ripple-ui-core/package.json b/packages/ripple-ui-core/package.json index e209b26ab3..deb40170db 100644 --- a/packages/ripple-ui-core/package.json +++ b/packages/ripple-ui-core/package.json @@ -1,7 +1,7 @@ { "name": "@dpc-sdp/ripple-ui-core", "description": "Ripple UI Core component library", - "version": "2.16.0", + "version": "2.17.0", "license": "Apache-2.0", "repository": "https://github.com/dpc-sdp/ripple-framework", "files": [ @@ -44,7 +44,7 @@ "test:generate-output": "jest --json --outputFile=.jest-test-results.json" }, "dependencies": { - "@nuxt/kit": "^3.11.2", + "@nuxt/kit": "3.13.2", "@vueuse/core": "^10.9.0", "@vueuse/integrations": "^10.9.0", "date-fns": "^2.29.3", diff --git a/packages/ripple-ui-core/src/components/accordion/RplAccordion.stories.mdx b/packages/ripple-ui-core/src/components/accordion/RplAccordion.stories.mdx index 55d857b270..f9aa352c82 100644 --- a/packages/ripple-ui-core/src/components/accordion/RplAccordion.stories.mdx +++ b/packages/ripple-ui-core/src/components/accordion/RplAccordion.stories.mdx @@ -6,6 +6,7 @@ import { } from '@storybook/addon-docs' import { a11yStoryCheck } from './../../../stories/interactions.js' import RplAccordion from './RplAccordion.vue' +import RplAccordionItem from './RplAccordionItem.vue' import SAMPLE from './fixtures/default.js' export const Template = (args) => ({ @@ -23,6 +24,29 @@ export const Template = (args) => ({ ` }) +export const SlotsTemplate = (args) => ({ + components: { RplAccordion, RplAccordionItem }, + setup() { + return { args } + }, + template: ` + + + +

Body content here

+
+ + +

Body content here

+
+
+ ` +}) + ({ name='Accordion numbered' play={a11yStoryCheck} args={{ - id: 'example', + id: 'example-numbered', items: SAMPLE, numbered: true }} @@ -63,3 +87,17 @@ export const Template = (args) => ({ {Template.bind()} + +## With slots + + + + {SlotsTemplate.bind()} + + diff --git a/packages/ripple-ui-core/src/components/accordion/RplAccordion.vue b/packages/ripple-ui-core/src/components/accordion/RplAccordion.vue index 67e0ed2ba7..c712565d5a 100644 --- a/packages/ripple-ui-core/src/components/accordion/RplAccordion.vue +++ b/packages/ripple-ui-core/src/components/accordion/RplAccordion.vue @@ -1,15 +1,13 @@ diff --git a/packages/ripple-ui-core/src/components/accordion/RplAccordionItem.vue b/packages/ripple-ui-core/src/components/accordion/RplAccordionItem.vue new file mode 100644 index 0000000000..956c6e565a --- /dev/null +++ b/packages/ripple-ui-core/src/components/accordion/RplAccordionItem.vue @@ -0,0 +1,128 @@ + + + diff --git a/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.css b/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.css index fb376e9268..8a63c2c561 100644 --- a/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.css +++ b/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.css @@ -10,9 +10,9 @@ display: inline-block; margin-left: var(--local-horizontal-offset); padding-top: calc(var(--rpl-sp-3) - var(--rpl-border-1)); - padding-right: calc(var(--rpl-sp-5) - var(--rpl-border-1)); + padding-right: calc(var(--rpl-sp-4) - var(--rpl-border-1)); padding-bottom: calc(var(--rpl-sp-3) - var(--rpl-border-1)); - padding-left: calc(var(--rpl-sp-5) - var(--rpl-border-1)); + padding-left: calc(var(--rpl-sp-4) - var(--rpl-border-1)); max-width: calc(100% - var(--local-horizontal-offset) * 2); overflow: hidden; text-overflow: ellipsis; @@ -21,6 +21,8 @@ @media (--rpl-bp-m) { --local-horizontal-offset: var(--rpl-sp-4); + padding-right: calc(var(--rpl-sp-5) - var(--rpl-border-1)); + padding-left: calc(var(--rpl-sp-5) - var(--rpl-border-1)); height: auto; overflow: visible; white-space: normal; @@ -84,6 +86,22 @@ &--beside-exit { --local-width-spacer: 124px; - max-width: calc(100% - var(--local-horizontal-offset) * 3 - var(--local-width-spacer)); + max-width: calc( + 100% - var(--local-horizontal-offset) * 3 - var(--local-width-spacer) + ); } } + +.rpl-breadcrumbs__collapse-link::before { + content: '>'; + display: inline; + padding: 0 var(--rpl-sp-2); +} + +.rpl-breadcrumbs__collapse-link-trigger:hover { + text-decoration: underline; +} + +.rpl-breadcrumbs__item--collapsed { + display: none; +} diff --git a/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.vue b/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.vue index c226cbcb0f..3e99df8446 100644 --- a/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.vue +++ b/packages/ripple-ui-core/src/components/breadcrumbs/RplBreadcrumbs.vue @@ -1,9 +1,11 @@