@shopware/cms-base-layer 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -23
- package/app/app.config.ts +3 -3
- package/app/assets/css/rich-text.css +80 -0
- package/app/components/SwCategoryNavigationLink.vue +4 -5
- package/app/components/SwContactForm.vue +2 -1
- package/app/components/SwFilterChips.vue +29 -7
- package/app/components/SwMedia3D.vue +8 -3
- package/app/components/SwNewsletterForm.vue +2 -1
- package/app/components/SwProductAddToCart.vue +27 -4
- package/app/components/SwProductCard.vue +26 -4
- package/app/components/SwProductCardDetails.vue +9 -2
- package/app/components/SwProductCardImage.vue +5 -1
- package/app/components/SwProductListingFilter.vue +6 -1
- package/app/components/SwProductListingFilters.vue +13 -189
- package/app/components/SwProductListingFiltersHorizontal.vue +20 -172
- package/app/components/SwStockInfo.vue +5 -2
- package/app/components/SwVariantConfigurator.vue +8 -3
- package/app/components/listing-filters/SwFilterCategories.vue +158 -0
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
- package/app/components/public/cms/CmsGenericBlock.vue +6 -10
- package/app/components/public/cms/CmsGenericElement.md +11 -5
- package/app/components/public/cms/CmsGenericElement.vue +17 -11
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
- package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
- package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
- package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
- package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
- package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
- package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
- package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
- package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
- package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
- package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
- package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
- package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
- package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
- package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
- package/app/components/public/cms/block/CmsBlockText.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
- package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
- package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
- package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
- package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
- package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
- package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
- package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
- package/app/components/public/cms/element/CmsElementImage.vue +41 -1
- package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
- package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
- package/app/components/public/cms/element/CmsElementText.md +77 -0
- package/app/components/public/cms/element/CmsElementText.vue +15 -41
- package/app/components/public/cms/element/CmsElementVideo.md +27 -0
- package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
- package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
- package/app/composables/useListingFilters.ts +189 -0
- package/app/composables/useSelectedListingFilters.ts +46 -0
- package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
- package/app/helpers/cms/getAppRendererLayout.ts +36 -0
- package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
- package/app/helpers/cms/getCategoryNameContent.ts +15 -0
- package/app/helpers/cms/getTextElementContent.test.ts +79 -0
- package/app/helpers/cms/getTextElementContent.ts +37 -0
- package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
- package/app/helpers/cms/getVideoElementOptions.ts +142 -0
- package/app/helpers/html-to-vue/renderToHtml.test.ts +71 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
- package/app/helpers/html-to-vue/renderer.ts +13 -4
- package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
- package/app/helpers/product/shouldShowBuyButton.ts +6 -0
- package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +14 -4
- package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +4 -33
- package/app/utils/listingFilters.test.ts +39 -0
- package/app/utils/listingFilters.ts +19 -0
- package/component-dirs.json +20 -0
- package/index.d.ts +2 -2
- package/nuxt.config.ts +11 -28
- package/package.json +23 -26
- package/dist/index.d.mts +0 -5
- package/dist/index.d.ts +0 -5
- package/dist/index.mjs +0 -31
- package/index.cjs +0 -7
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import type { ComputedRef } from "vue";
|
|
3
|
+
import type { LocationQueryRaw } from "vue-router";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
firstQueryValue,
|
|
7
|
+
getVisibleListingFilters,
|
|
8
|
+
toNumber,
|
|
9
|
+
useCategoryListing,
|
|
10
|
+
useProductSearchListing,
|
|
11
|
+
useRoute,
|
|
12
|
+
useRouter,
|
|
13
|
+
useSelectedListingFilters,
|
|
14
|
+
} from "#imports";
|
|
15
|
+
import type { Schemas } from "#shopware";
|
|
16
|
+
|
|
17
|
+
/** Shared by the sidebar and horizontal filter components. */
|
|
18
|
+
export function useListingFilters(isProductSearch: boolean) {
|
|
19
|
+
const route = useRoute();
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
|
|
22
|
+
const { getCurrentSortingOrder, getInitialFilters, getSortingOrders } =
|
|
23
|
+
isProductSearch ? useProductSearchListing() : useCategoryListing();
|
|
24
|
+
|
|
25
|
+
const selectedFilters = useSelectedListingFilters();
|
|
26
|
+
|
|
27
|
+
// Only the filters this listing can apply; the category filter is search-only.
|
|
28
|
+
const visibleFilters = computed(() =>
|
|
29
|
+
getVisibleListingFilters(getInitialFilters.value, { isProductSearch }),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const showResetFiltersButton = computed<boolean>(
|
|
33
|
+
() =>
|
|
34
|
+
selectedFilters.manufacturer.size !== 0 ||
|
|
35
|
+
selectedFilters.properties.size !== 0 ||
|
|
36
|
+
(isProductSearch && selectedFilters.categories.size !== 0) ||
|
|
37
|
+
!!selectedFilters["max-price"] ||
|
|
38
|
+
!!selectedFilters["min-price"] ||
|
|
39
|
+
!!selectedFilters.rating ||
|
|
40
|
+
!!selectedFilters["shipping-free"],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const searchCriteriaForRequest: ComputedRef<
|
|
44
|
+
Schemas["ProductListingCriteria"]
|
|
45
|
+
> = computed(() => ({
|
|
46
|
+
manufacturer: [...(selectedFilters.manufacturer as Set<string>)]?.join("|"),
|
|
47
|
+
properties: [...(selectedFilters.properties as Set<string>)]?.join("|"),
|
|
48
|
+
"min-price": selectedFilters["min-price"] as number,
|
|
49
|
+
"max-price": selectedFilters["max-price"] as number,
|
|
50
|
+
order: getCurrentSortingOrder.value as string,
|
|
51
|
+
"shipping-free": selectedFilters["shipping-free"] as boolean,
|
|
52
|
+
rating: selectedFilters.rating as number,
|
|
53
|
+
search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
|
|
54
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const executeSearch = async () => {
|
|
58
|
+
try {
|
|
59
|
+
const criteria = searchCriteriaForRequest.value;
|
|
60
|
+
const query: Record<string, unknown> = {};
|
|
61
|
+
|
|
62
|
+
if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
|
|
63
|
+
if (criteria.properties) query.properties = criteria.properties;
|
|
64
|
+
if (isProductSearch && selectedFilters.categories.size > 0)
|
|
65
|
+
query.categories = [
|
|
66
|
+
...(selectedFilters.categories as Set<string>),
|
|
67
|
+
].join("|");
|
|
68
|
+
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
|
|
69
|
+
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
|
|
70
|
+
if (criteria.rating) query.rating = criteria.rating;
|
|
71
|
+
if (criteria["shipping-free"])
|
|
72
|
+
query["shipping-free"] = criteria["shipping-free"];
|
|
73
|
+
if (criteria.order) query.order = criteria.order;
|
|
74
|
+
// Keep the search term so filtering does not reset the query.
|
|
75
|
+
if (isProductSearch && route.query.search)
|
|
76
|
+
query.search = route.query.search;
|
|
77
|
+
// Preserve the page size, which rebuilding the query would drop.
|
|
78
|
+
if (route.query.limit) query.limit = route.query.limit;
|
|
79
|
+
|
|
80
|
+
// URL first: gating it on the request lost the filter when the call
|
|
81
|
+
// failed. The listing refetches from the URL, so this is the whole job.
|
|
82
|
+
await router.push({ query: query as LocationQueryRaw });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("Search execution failed:", error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleFilterChange = async (event: {
|
|
89
|
+
code: string;
|
|
90
|
+
value: string | number | boolean;
|
|
91
|
+
}) => {
|
|
92
|
+
try {
|
|
93
|
+
const { code, value } = event;
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
code === "manufacturer" ||
|
|
97
|
+
code === "properties" ||
|
|
98
|
+
code === "categories"
|
|
99
|
+
) {
|
|
100
|
+
const filterSet = selectedFilters[code];
|
|
101
|
+
const stringValue = String(value);
|
|
102
|
+
if (filterSet.has(stringValue)) filterSet.delete(stringValue);
|
|
103
|
+
else filterSet.add(stringValue);
|
|
104
|
+
} else if (code === "min-price" || code === "max-price") {
|
|
105
|
+
selectedFilters[code] =
|
|
106
|
+
typeof value === "number" ? value : Number(value);
|
|
107
|
+
} else if (code === "rating") {
|
|
108
|
+
selectedFilters.rating = Number(value);
|
|
109
|
+
} else if (code === "shipping-free") {
|
|
110
|
+
selectedFilters["shipping-free"] = Boolean(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await executeSearch();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error("Filter update failed:", error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const clearFilters = () => {
|
|
120
|
+
(selectedFilters.manufacturer as Set<string>).clear();
|
|
121
|
+
(selectedFilters.properties as Set<string>).clear();
|
|
122
|
+
(selectedFilters.categories as Set<string>).clear();
|
|
123
|
+
selectedFilters["min-price"] = undefined;
|
|
124
|
+
selectedFilters["max-price"] = undefined;
|
|
125
|
+
selectedFilters.rating = undefined;
|
|
126
|
+
selectedFilters["shipping-free"] = undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
async function invokeCleanFilters() {
|
|
130
|
+
try {
|
|
131
|
+
clearFilters();
|
|
132
|
+
await executeSearch();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error("Clear filters failed:", error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const currentSortingOrder = computed({
|
|
139
|
+
get: (): string => getCurrentSortingOrder.value || "",
|
|
140
|
+
set: async (order: string): Promise<void> => {
|
|
141
|
+
try {
|
|
142
|
+
// Pushing is the whole job; the listing refetches from the URL.
|
|
143
|
+
await router.push({ query: { ...route.query, order } });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error("Sorting order change failed:", error);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const handleSortChange = (sortKey: string) => {
|
|
151
|
+
currentSortingOrder.value = sortKey;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleRemoveFilterChip = async (chip: {
|
|
155
|
+
code: string;
|
|
156
|
+
value: string | number;
|
|
157
|
+
}) => {
|
|
158
|
+
if (
|
|
159
|
+
chip.code === "properties" ||
|
|
160
|
+
chip.code === "manufacturer" ||
|
|
161
|
+
chip.code === "categories"
|
|
162
|
+
) {
|
|
163
|
+
(selectedFilters[chip.code] as Set<string>).delete(String(chip.value));
|
|
164
|
+
} else if (chip.code === "price") {
|
|
165
|
+
selectedFilters["min-price"] = undefined;
|
|
166
|
+
selectedFilters["max-price"] = undefined;
|
|
167
|
+
} else if (chip.code === "rating") {
|
|
168
|
+
selectedFilters.rating = undefined;
|
|
169
|
+
} else if (chip.code === "shipping-free") {
|
|
170
|
+
selectedFilters["shipping-free"] = undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await executeSearch();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
currentSortingOrder,
|
|
178
|
+
getCurrentSortingOrder,
|
|
179
|
+
executeSearch,
|
|
180
|
+
getSortingOrders,
|
|
181
|
+
handleFilterChange,
|
|
182
|
+
handleRemoveFilterChip,
|
|
183
|
+
handleSortChange,
|
|
184
|
+
invokeCleanFilters,
|
|
185
|
+
selectedFilters,
|
|
186
|
+
showResetFiltersButton,
|
|
187
|
+
visibleFilters,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { reactive, watch } from "vue";
|
|
2
|
+
import type { UnwrapNestedRefs } from "vue";
|
|
3
|
+
|
|
4
|
+
// Nuxt's useRoute, never vue-router's: this layer does not depend on
|
|
5
|
+
// vue-router, so a bare import resolves to whichever copy the consumer's
|
|
6
|
+
// package manager hoisted. When that is not the copy Nuxt's router uses, its
|
|
7
|
+
// inject() returns undefined and every page with a listing filter 500s in SSR.
|
|
8
|
+
import {
|
|
9
|
+
applyQueryToFilters,
|
|
10
|
+
createEmptyFilterState,
|
|
11
|
+
useRoute,
|
|
12
|
+
} from "#imports";
|
|
13
|
+
|
|
14
|
+
import type { FilterState } from "../utils/listingFilterState";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the reactive filter state, populated from the current route.query at
|
|
18
|
+
* setup (runs on server AND client -> identical first render, no hydration
|
|
19
|
+
* mismatch) and kept in sync on every subsequent query-only navigation
|
|
20
|
+
* (Back/Forward, manual URL edits, the component's own executeSearch push).
|
|
21
|
+
*
|
|
22
|
+
* The watcher is intentionally NOT immediate: the setup-time apply already
|
|
23
|
+
* covers the first render, and an immediate watcher would re-run during
|
|
24
|
+
* hydration. It only resyncs UI state - it never navigates or fetches - so it
|
|
25
|
+
* cannot loop with executeSearch() nor cause an extra listing request.
|
|
26
|
+
*/
|
|
27
|
+
export const useSelectedListingFilters = (): UnwrapNestedRefs<FilterState> => {
|
|
28
|
+
const route = useRoute();
|
|
29
|
+
// Fresh state per call, deliberately: the sidebar and the horizontal filter
|
|
30
|
+
// bar each hold their own selection object, so a mutation in one is invisible
|
|
31
|
+
// to the other until it reaches the route. The URL is what synchronises them.
|
|
32
|
+
const state = reactive<FilterState>(createEmptyFilterState());
|
|
33
|
+
|
|
34
|
+
// Initial parse (SSR + first client render).
|
|
35
|
+
applyQueryToFilters(state as FilterState, route.query);
|
|
36
|
+
|
|
37
|
+
// Resync on query-only route changes while the component stays mounted.
|
|
38
|
+
watch(
|
|
39
|
+
() => route.query,
|
|
40
|
+
(query) => {
|
|
41
|
+
applyQueryToFilters(state as FilterState, query);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return state;
|
|
46
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CmsBlockAppRenderer } from "@shopware/composables";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { getAppRendererLayout } from "./getAppRendererLayout";
|
|
5
|
+
|
|
6
|
+
const block = (
|
|
7
|
+
customFields: CmsBlockAppRenderer["customFields"],
|
|
8
|
+
slots: string[] = [],
|
|
9
|
+
) =>
|
|
10
|
+
({
|
|
11
|
+
type: "app-renderer",
|
|
12
|
+
customFields,
|
|
13
|
+
slots: slots.map((slot) => ({ id: slot, slot, type: slot.split("-")[0] })),
|
|
14
|
+
}) as unknown as CmsBlockAppRenderer;
|
|
15
|
+
|
|
16
|
+
describe("getAppRendererLayout", () => {
|
|
17
|
+
it("reads the app block name and grid", () => {
|
|
18
|
+
const layout = getAppRendererLayout(
|
|
19
|
+
block({
|
|
20
|
+
appBlockName: "swag-two-columns",
|
|
21
|
+
slotLayout: { grid: "auto / auto auto" },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(layout.appBlockName).toBe("swag-two-columns");
|
|
26
|
+
expect(layout.grid).toBe("auto / auto auto");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
["swag-two-columns", "CmsBlockAppRendererSwagTwoColumns"],
|
|
31
|
+
["SwagTwoColumns", "CmsBlockAppRendererSwagTwoColumns"],
|
|
32
|
+
["swag_two_columns", "CmsBlockAppRendererSwagTwoColumns"],
|
|
33
|
+
])(
|
|
34
|
+
"derives the override component name from %s",
|
|
35
|
+
(appBlockName, expected) => {
|
|
36
|
+
expect(getAppRendererLayout(block({ appBlockName })).componentName).toBe(
|
|
37
|
+
expected,
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it("has no override component and no grid without custom fields", () => {
|
|
43
|
+
const layout = getAppRendererLayout(block(null));
|
|
44
|
+
|
|
45
|
+
expect(layout.appBlockName).toBe("");
|
|
46
|
+
expect(layout.componentName).toBeUndefined();
|
|
47
|
+
expect(layout.grid).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("restores the declared order from the name-sorted Store API order", () => {
|
|
51
|
+
const layout = getAppRendererLayout(
|
|
52
|
+
block({ appBlockName: "swag" }, [
|
|
53
|
+
"image-1",
|
|
54
|
+
"image-3",
|
|
55
|
+
"text-0",
|
|
56
|
+
"text-10",
|
|
57
|
+
"text-2",
|
|
58
|
+
]),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(layout.slots.map((slot) => slot.slot)).toEqual([
|
|
62
|
+
"text-0",
|
|
63
|
+
"image-1",
|
|
64
|
+
"text-2",
|
|
65
|
+
"image-3",
|
|
66
|
+
"text-10",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps slots without an index after the indexed ones, in their order", () => {
|
|
71
|
+
const layout = getAppRendererLayout(
|
|
72
|
+
block({ appBlockName: "swag" }, ["content", "text-1", "left", "text-0"]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(layout.slots.map((slot) => slot.slot)).toEqual([
|
|
76
|
+
"text-0",
|
|
77
|
+
"text-1",
|
|
78
|
+
"content",
|
|
79
|
+
"left",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not reorder the block's own slots", () => {
|
|
84
|
+
const content = block({ appBlockName: "swag" }, ["text-1", "text-0"]);
|
|
85
|
+
|
|
86
|
+
getAppRendererLayout(content);
|
|
87
|
+
|
|
88
|
+
expect(content.slots.map((slot) => slot.slot)).toEqual([
|
|
89
|
+
"text-1",
|
|
90
|
+
"text-0",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CmsBlockAppRenderer } from "@shopware/composables";
|
|
2
|
+
import { pascalCase } from "scule";
|
|
3
|
+
|
|
4
|
+
type AppRendererSlot = CmsBlockAppRenderer["slots"][number];
|
|
5
|
+
|
|
6
|
+
export type AppRendererLayout = {
|
|
7
|
+
appBlockName: string;
|
|
8
|
+
componentName?: string;
|
|
9
|
+
grid?: string;
|
|
10
|
+
slots: AppRendererSlot[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SLOT_INDEX = /-(\d+)$/;
|
|
14
|
+
|
|
15
|
+
function getSlotIndex(slot: AppRendererSlot): number {
|
|
16
|
+
const match = SLOT_INDEX.exec(slot.slot ?? "");
|
|
17
|
+
|
|
18
|
+
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getAppRendererLayout(
|
|
22
|
+
block: CmsBlockAppRenderer,
|
|
23
|
+
): AppRendererLayout {
|
|
24
|
+
const appBlockName = block.customFields?.appBlockName ?? "";
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
appBlockName,
|
|
28
|
+
componentName: appBlockName
|
|
29
|
+
? pascalCase(`CmsBlockAppRenderer-${appBlockName}`)
|
|
30
|
+
: undefined,
|
|
31
|
+
grid: block.customFields?.slotLayout?.grid || undefined,
|
|
32
|
+
slots: [...(block.slots ?? [])].sort(
|
|
33
|
+
(a, b) => getSlotIndex(a) - getSlotIndex(b),
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CmsElementCategoryName } from "@shopware/composables";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { getCategoryNameContent } from "./getCategoryNameContent";
|
|
5
|
+
|
|
6
|
+
const element = (source: "static" | "mapped", content: string | null) =>
|
|
7
|
+
({
|
|
8
|
+
type: "category-name",
|
|
9
|
+
config: {
|
|
10
|
+
content: {
|
|
11
|
+
source,
|
|
12
|
+
value: source === "mapped" ? "category.name" : "<p>static</p>",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
data: { content, apiAlias: "cms_text" },
|
|
16
|
+
}) as unknown as CmsElementCategoryName;
|
|
17
|
+
|
|
18
|
+
describe("getCategoryNameContent", () => {
|
|
19
|
+
it("wraps mapped content in the page headline", () => {
|
|
20
|
+
expect(getCategoryNameContent(element("mapped", "Clothing"))).toBe(
|
|
21
|
+
'<h1 class="cms-element-category-name-headline">Clothing</h1>',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps entities the backend sanitizer produced", () => {
|
|
26
|
+
expect(getCategoryNameContent(element("mapped", "Shoes & Bags"))).toBe(
|
|
27
|
+
'<h1 class="cms-element-category-name-headline">Shoes & Bags</h1>',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns static content as authored", () => {
|
|
32
|
+
expect(
|
|
33
|
+
getCategoryNameContent(element("static", "<h2>Our <b>range</b></h2>")),
|
|
34
|
+
).toBe("<h2>Our <b>range</b></h2>");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders no empty headline when the mapped value did not resolve", () => {
|
|
38
|
+
expect(getCategoryNameContent(element("mapped", null))).toBe("");
|
|
39
|
+
expect(getCategoryNameContent(element("mapped", ""))).toBe("");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders no headline around a mapping path the backend returned", () => {
|
|
43
|
+
const leakedPath = {
|
|
44
|
+
type: "category-name",
|
|
45
|
+
config: {
|
|
46
|
+
content: { source: "mapped", value: "category.customFields" },
|
|
47
|
+
},
|
|
48
|
+
data: { content: "category.customFields", apiAlias: "cms_text" },
|
|
49
|
+
} as unknown as CmsElementCategoryName;
|
|
50
|
+
|
|
51
|
+
expect(getCategoryNameContent(leakedPath)).toBe("");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns nothing for static content that did not resolve", () => {
|
|
55
|
+
expect(getCategoryNameContent(element("static", null))).toBe("");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CmsElementCategoryName } from "@shopware/composables";
|
|
2
|
+
|
|
3
|
+
import { getTextElementContent } from "./getTextElementContent";
|
|
4
|
+
|
|
5
|
+
export function getCategoryNameContent(
|
|
6
|
+
element: CmsElementCategoryName,
|
|
7
|
+
): string {
|
|
8
|
+
const content = getTextElementContent(element);
|
|
9
|
+
|
|
10
|
+
if (!content || element.config?.content?.source !== "mapped") {
|
|
11
|
+
return content;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return `<h1 class="cms-element-category-name-headline">${content}</h1>`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getTextElementContent } from "./getTextElementContent";
|
|
4
|
+
|
|
5
|
+
const element = (
|
|
6
|
+
source: "static" | "mapped",
|
|
7
|
+
value: string,
|
|
8
|
+
data: { content: string | null } | null,
|
|
9
|
+
) => ({ config: { content: { source, value } }, data });
|
|
10
|
+
|
|
11
|
+
describe("getTextElementContent", () => {
|
|
12
|
+
it("renders the content the backend resolved", () => {
|
|
13
|
+
expect(
|
|
14
|
+
getTextElementContent(
|
|
15
|
+
element("static", "<p>{{ category.name }}</p>", {
|
|
16
|
+
content: "<p>Clothing</p>",
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
).toBe("<p>Clothing</p>");
|
|
20
|
+
expect(
|
|
21
|
+
getTextElementContent(
|
|
22
|
+
element("mapped", "category.name", { content: "Clothing" }),
|
|
23
|
+
),
|
|
24
|
+
).toBe("Clothing");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not fall back to the unsanitized config when the backend sanitized the content away", () => {
|
|
28
|
+
expect(
|
|
29
|
+
getTextElementContent(
|
|
30
|
+
element("static", "<script>alert(1)</script>", { content: "" }),
|
|
31
|
+
),
|
|
32
|
+
).toBe("");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not render a placeholder that resolved to nothing", () => {
|
|
36
|
+
expect(
|
|
37
|
+
getTextElementContent(
|
|
38
|
+
element("static", "{{ category.description }}", { content: "" }),
|
|
39
|
+
),
|
|
40
|
+
).toBe("");
|
|
41
|
+
expect(
|
|
42
|
+
getTextElementContent(
|
|
43
|
+
element("static", "{{ category.description }}", { content: null }),
|
|
44
|
+
),
|
|
45
|
+
).toBe("");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not render a mapping path", () => {
|
|
49
|
+
expect(
|
|
50
|
+
getTextElementContent(
|
|
51
|
+
element("mapped", "category.name", { content: null }),
|
|
52
|
+
),
|
|
53
|
+
).toBe("");
|
|
54
|
+
expect(
|
|
55
|
+
getTextElementContent(
|
|
56
|
+
element("mapped", "category.customFields", {
|
|
57
|
+
content: "category.customFields",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
).toBe("");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders static config for an element without data", () => {
|
|
64
|
+
expect(getTextElementContent(element("static", "<p>Hi</p>", null))).toBe(
|
|
65
|
+
"<p>Hi</p>",
|
|
66
|
+
);
|
|
67
|
+
expect(
|
|
68
|
+
getTextElementContent(element("mapped", "category.name", null)),
|
|
69
|
+
).toBe("");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders nothing for a missing element or config", () => {
|
|
73
|
+
expect(getTextElementContent(undefined)).toBe("");
|
|
74
|
+
expect(getTextElementContent({ config: null, data: null })).toBe("");
|
|
75
|
+
expect(getTextElementContent({ data: { content: "<p>x</p>" } })).toBe(
|
|
76
|
+
"<p>x</p>",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type TextElementLike = {
|
|
2
|
+
config?: {
|
|
3
|
+
content?: { source?: string; value?: unknown } | null;
|
|
4
|
+
} | null;
|
|
5
|
+
data?: { content?: string | null } | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The HTML a text-like element (`text`, `product-name`, `category-name`)
|
|
10
|
+
* renders.
|
|
11
|
+
*
|
|
12
|
+
* Once the Store API resolved the slot, `data.content` is the only source: the
|
|
13
|
+
* backend sanitized it, `config.content.value` is the raw input. The config is
|
|
14
|
+
* read only for an element without `data`, which the API never sends but a
|
|
15
|
+
* hand-built element may.
|
|
16
|
+
*/
|
|
17
|
+
export function getTextElementContent(
|
|
18
|
+
element: TextElementLike | null | undefined,
|
|
19
|
+
): string {
|
|
20
|
+
const config = element?.config?.content;
|
|
21
|
+
|
|
22
|
+
if (!element?.data) {
|
|
23
|
+
return config?.source !== "mapped" && typeof config?.value === "string"
|
|
24
|
+
? config.value
|
|
25
|
+
: "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const content = element.data.content ?? "";
|
|
29
|
+
|
|
30
|
+
// The backend answers a mapping to a value that is not a string (an entity,
|
|
31
|
+
// an array) with the mapping path itself, e.g. `category.customFields`.
|
|
32
|
+
if (config?.source === "mapped" && content === config.value) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return content;
|
|
37
|
+
}
|