CASH-2378: create new radio button for multi location modal usage

This commit is contained in:
Adam Caouette 2026-03-04 08:00:39 -05:00
parent 664a7d289d
commit 0c32eecdcc
3 changed files with 433 additions and 0 deletions

View file

@ -226,6 +226,7 @@ export default {
let classes;
switch (this.buttonTypeString) {
case "timeSlotModalListButton":
case "multiLocationRadioButton":
case "listButton":
classes = "w-100";
break;
@ -265,6 +266,8 @@ export default {
classes += " radio-button-container";
} else if (this.buttonTypeString == "servicePackageRadio") {
classes = "package-wrapper col";
} else if (this.buttonTypeString == "multiLocationRadioButton") {
classes = "multi-location-wrapper col";
}
if (this.buttonTypeString == "listCard" && this.buttonsInfo.length > 2) {

View file

@ -0,0 +1,121 @@
import { mount } from "@vue/test-utils";
import multiLocationRadio from "./multi-location-radio";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
describe("multi-location-radio.vue", () => {
it("Should include buttonLabel in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabel"]));
});
it("Should include buttonLabelSubCopy in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabelSubCopy"]));
});
it("Should include buttonFooterCopy in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonFooterCopy"]));
});
it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when provided with a <ul><li>...</li>(x5)</ul> buttonBodyCopy", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
expect(results.length).toBe(5);
});
it("Should get strings from getArrayOfListItemsFromRawCmsCopy without <ul> or <li> tags when provided with a <ul><li>...</li></ul> buttonBodyCopy", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
const fileredResults = results.filter((result) => {
return (
result.includes("<ul>") ||
result.includes("</ul>") ||
result.includes("<li>") ||
result.includes("</li>")
);
});
expect(fileredResults.length).toBe(0);
});
it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when buttonBodyCopy has 6 total <li>...</li>, but one is empty ", async () => {
// Arrange
const moddedProps = mockProps;
moddedProps["buttonBodyCopy"] =
"<ul><li>buttonBodyCopy test copy</li><li>2</li><li>3</li><li>4</li><li>5</li><li></li></ul>";
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: moddedProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
expect(results.length).toBe(5);
});
});
const mockProps = {
groupName: "group-name",
buttonLabel: "buttonLabel test copy",
buttonLabelAuxillaryCopy: "buttonLabelAuxillaryCopy test copy",
buttonLabelSubCopy: "buttonLabelSubCopy test copy",
buttonBodyCopy:
"<ul><li>buttonBodyCopy test copy</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>",
buttonFooterCopy: "buttonFooterCopy test copy",
buttonAuxillaryCopy: "buttonAuxillaryCopy test copy",
};
function setupMocks({ mountOptionsMockData = {} }) {
const wrapper = mount(multiLocationRadio, {
...mountOptionsMockData,
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}

View file

@ -0,0 +1,309 @@
<template>
<baseInputButton v-bind="$props" @buttonClicked="handleAnswerChange" v-model="selectedValue">
<div class="location-label" for="testradio">
<div class="location-specs">
<div class="row">
<div class="col md-6">
<p class="m-0">
<span v-html="this.buttonLabel"></span>
</p>
<span v-html="this.buttonAuxillaryCopy"></span>
<p
class="sub-label m-0"
v-if="this.buttonLabelSubCopy"
v-html="this.buttonLabelSubCopy"></p>
</div>
<hr />
<div class="col md-6 location-info">
<div
class="location-info-label"
v-if="this.buttonBodyCopy"
v-html="this.buttonBodyCopy"></div>
</div>
<div class="time-info" v-html="this.buttonFooterCopy"></div>
</div>
</div>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import textLink from "@/ux-components/text-link/text-link";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import {
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
} from "@/helpers/cms-content-helper";
export default {
name: "multiLocationRadioButton",
mixins: [inputButtonWrapperMixin],
components: {
baseInputButton,
},
computed: {
arrayOfListItemsFromBodyText() {
return this.getArrayOfListItemsFromRawCmsCopy(this.buttonBodyCopy);
},
shouldDisplayStrikeThroughPrice() {
const displayPrice = this.buttonAuxillaryCopy?.substring(
this.buttonAuxillaryCopy.indexOf("$")
);
return displayPrice != this.additionalButtonData?.strikeThroughPrice;
},
},
methods: {
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
stripUlTagFromCopy(copy) {
const regex = /(?:<ul(?:.*?)>)|(?:<\/ul>)/g;
return copy?.replace(regex, "");
},
getArrayOfListItemsFromRawCmsCopy(copy) {
const withoutUlTags = this.stripUlTagFromCopy(copy);
return withoutUlTags
?.split(/(?:<li(?:.*?)>)|(?:<\/li>)/g)
?.filter((lineItem) => lineItem);
},
},
};
</script>
<style lang="scss">
.multi-location-wrapper {
margin: 1rem 0;
&:first-child {
margin: 1rem 0 0 0;
}
&:last-child {
margin: 1rem 0 0 0;
}
@include media-breakpoint-up(md) {
margin: 1rem 0.5rem 0 0.5rem;
display: flex;
flex-direction: column;
&:first-child {
margin: 1rem 0.5rem 0 0;
}
&:last-child {
margin: 1rem 0 0 0.5rem;
}
}
label {
display: block;
@include media-breakpoint-up(md) {
display: flex;
height: 100%;
}
}
input[type="radio"] {
opacity: 0;
position: absolute;
left: -9999px;
+ .location-label {
display: flex;
align-items: flex-start;
position: relative;
cursor: pointer;
width: 100%;
padding: 0.75rem;
border: 1px solid $gray-300;
box-shadow:
0px 4px 8px -4px rgba(0, 0, 0, 0.15),
0px 4px 24px -8px rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
overflow: hidden;
min-height: 52px;
max-height: 126px;
@include media-breakpoint-up(md) {
max-height: 500px;
padding-bottom: 3rem;
}
transition: max-height 0.25s ease-in;
&.has-subheader {
min-height: 72px;
}
&:before {
content: "";
position: relative;
top: 5px;
margin-right: 0.5rem;
border-radius: 50%;
border: 1px solid $gray-500;
width: 16px;
height: 16px;
min-width: 16px;
}
&:after {
content: "";
position: absolute;
left: 15px;
top: 20px;
border-radius: 50%;
width: 10px;
height: 10px;
min-width: 10px;
}
}
&:hover {
+ .location-label {
&:before {
border: 1px solid #8e9292;
box-shadow:
0px 0px 0px 4px #9fcee6,
0px 1px 4px rgba(0, 0, 0, 0.2);
}
}
}
&:checked {
+ .location-label {
.location-specs {
max-height: 1000px;
}
}
+ .location-label {
.location-specs {
.hide-when-closed {
display: block;
}
p:first-child {
font-weight: 600;
}
}
}
+ .location-label {
border: 1px solid $blue;
max-height: 500px;
background-color: $blue-100;
.special-save-box {
background-color: $green-200;
}
}
+ .location-label {
&:before {
box-shadow: 0px 0px 0px 1px $blue;
}
}
+ .location-label {
&:after {
background: $blue;
}
}
}
&:focus {
+ .location-label {
&:before {
border: 2px solid $blue;
}
}
}
}
.package-footer {
color: $red;
font-weight: 600;
}
.location-specs {
display: flex;
flex-direction: column;
width: 100%;
transition: all 0.5s ease;
@include media-breakpoint-up(md) {
max-height: 500px;
}
.row {
align-items: center;
}
.pricing-info .insurance-pricing span {
font-size: 0.875rem;
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
}
p {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
display: flex;
justify-content: space-between;
align-items: center;
white-space: nowrap;
&:first-child {
font-weight: 400;
}
span {
&.pricing-info {
color: $green;
font-size: 0.875rem;
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
@include media-breakpoint-up(md) {
position: absolute;
bottom: 0.75rem;
left: 0;
width: 100%;
margin-left: 1.25rem;
}
span.strikethrough-price {
text-decoration: line-through;
font-size: 0.75rem;
font-weight: 400;
color: $gray-550;
padding-right: 8px;
}
}
}
&.sub-label {
color: $green;
text-transform: uppercase;
font-size: 0.75rem;
}
}
ul {
margin: 1rem 0 0 -5px;
padding: 0;
li {
font-size: 0.875rem;
line-height: 1.714;
color: $gray-550;
a {
line-height: 1.714;
padding: 0;
}
}
}
@keyframes slideaway {
from {
display: block;
}
to {
transform: translateY(40px);
opacity: 0;
}
}
.hide-when-closed {
display: none;
@include media-breakpoint-up(md) {
display: flex;
flex-direction: column;
}
}
}
}
</style>